feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
-21
View File
@@ -1,21 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
require('dotenv/config');
var env_1 = require('./config/env');
var ffmpeg_1 = require('./utils/ffmpeg');
var logger_1 = require('./utils/logger');
void (0, ffmpeg_1.warnIfFfmpegMissing)();
if (!env_1.env.REDIS_URL) {
throw new Error('REDIS_URL is required to start a worker process.');
}
logger_1.log.info('worker-bootstrap', {
nodeEnv: env_1.env.NODE_ENV,
pid: process.pid,
hasOpenAi: Boolean(env_1.env.OPENAI_API_KEY)
});
process.on('uncaughtException', function (error) {
logger_1.log.error('uncaught-exception', { message: error.message, stack: error.stack });
});
process.on('unhandledRejection', function (reason) {
logger_1.log.error('unhandled-rejection', { reason: reason instanceof Error ? reason.message : String(reason) });
});
View File
+2 -1
View File
@@ -28,8 +28,9 @@ const envSchema = z.object({
MEDIA_WORKER_CONCURRENCY: z.string().optional(),
TRANSCRIBE_WORKER_CONCURRENCY: z.string().optional(),
EMAIL_WORKER_CONCURRENCY: z.string().optional(),
AGENT_COURSE_GENERATION_WORKER_CONCURRENCY: z.string().optional(),
/** Optional OpenAI Whisper key — when unset, transcribe-audio jobs no-op. */
/** Optional OpenAI key — when unset, transcribe-audio jobs no-op and OpenAI-backed agent runs fail config checks. */
OPENAI_API_KEY: z.string().optional()
});
-78
View File
@@ -1,78 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.getStorageConfig = getStorageConfig;
exports.getS3Client = getS3Client;
var client_s3_1 = require('@aws-sdk/client-s3');
var env_1 = require('./env');
/**
* Worker-side S3 client. Mirrors apps/api/src/config/storage.ts so workers
* can stream the same buckets the API uploads to. Kept self-contained so
* apps/jobs has no runtime dependency on apps/api.
*/
var BUCKET_DEFAULTS = {
VIDEOS: 'videos',
DOCUMENTS: 'documents',
MEDIA: 'media'
};
function useS3CompatibleConfig() {
return Boolean(
env_1.env.OBJECT_STORAGE_ENDPOINT &&
env_1.env.OBJECT_STORAGE_ACCESS_KEY_ID &&
env_1.env.OBJECT_STORAGE_SECRET_ACCESS_KEY
);
}
function isCloudflareConfigured() {
return Boolean(
env_1.env.CLOUDFLARE_ACCOUNT_ID && env_1.env.CLOUDFLARE_ACCESS_KEY && env_1.env.CLOUDFLARE_SECRET_ACCESS_KEY
);
}
var cachedConfig = null;
function getStorageConfig() {
if (cachedConfig) return cachedConfig;
if (useS3CompatibleConfig()) {
cachedConfig = {
endpoint: env_1.env.OBJECT_STORAGE_ENDPOINT,
region: env_1.env.OBJECT_STORAGE_REGION || 'us-east-1',
accessKeyId: env_1.env.OBJECT_STORAGE_ACCESS_KEY_ID,
secretAccessKey: env_1.env.OBJECT_STORAGE_SECRET_ACCESS_KEY,
forcePathStyle:
env_1.env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true' || env_1.env.OBJECT_STORAGE_FORCE_PATH_STYLE === '1',
bucketVideos: env_1.env.OBJECT_STORAGE_BUCKET_VIDEOS || BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: env_1.env.OBJECT_STORAGE_BUCKET_DOCUMENTS || BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: env_1.env.OBJECT_STORAGE_BUCKET_MEDIA || BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env_1.env.OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL || null
};
} else if (isCloudflareConfigured()) {
cachedConfig = {
endpoint: 'https://'.concat(env_1.env.CLOUDFLARE_ACCOUNT_ID, '.r2.cloudflarestorage.com'),
region: 'auto',
accessKeyId: env_1.env.CLOUDFLARE_ACCESS_KEY,
secretAccessKey: env_1.env.CLOUDFLARE_SECRET_ACCESS_KEY,
forcePathStyle: false,
bucketVideos: BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env_1.env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN || null
};
} else {
throw new Error(
'Object storage not configured. Set OBJECT_STORAGE_* (MinIO/S3) or CLOUDFLARE_* (R2) env vars on apps/jobs.'
);
}
return cachedConfig;
}
var s3Client = null;
function getS3Client() {
if (s3Client) return s3Client;
var config = getStorageConfig();
s3Client = new client_s3_1.S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
},
forcePathStyle: config.forcePathStyle
});
return s3Client;
}
-11
View File
@@ -1,11 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
require('./bootstrap');
require('./workers/media');
require('./workers/media-transcribe');
require('./workers/emails');
require('./workers/maintenance');
var logger_1 = require('./utils/logger');
logger_1.log.info('all-workers-running', {
workers: ['media', 'media-transcribe', 'emails', 'maintenance']
});
+2 -1
View File
@@ -4,9 +4,10 @@ import './workers/media';
import './workers/media-transcribe';
import './workers/emails';
import './workers/maintenance';
import './workers/agent-course-generation';
import { log } from './utils/logger';
log.info('all-workers-running', {
workers: ['media', 'media-transcribe', 'emails', 'maintenance']
workers: ['media', 'media-transcribe', 'emails', 'maintenance', 'agent-course-generation']
});
-29
View File
@@ -1,29 +0,0 @@
'use strict';
var __createBinding =
(this && this.__createBinding) ||
(Object.create
? function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = {
enumerable: true,
get: function () {
return m[k];
}
};
}
Object.defineProperty(o, k2, desc);
}
: function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
});
var __exportStar =
(this && this.__exportStar) ||
function (m, exports) {
for (var p in m)
if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, '__esModule', { value: true });
__exportStar(require('./send'), exports);
-217
View File
@@ -1,217 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processSendEmail = processSendEmail;
var email_1 = require('@cio/email');
var jobs_1 = require('@cio/jobs');
var logger_1 = require('../../utils/logger');
/**
* Process an `emails:send` job. Dispatches based on payload `kind`, calls the
* provider via `@cio/email`, and returns a provider message id (BullMQ stores
* this as the job return value).
*/
function processSendEmail(rawPayload) {
return __awaiter(this, void 0, void 0, function () {
var payload, responses_1, result_1, responses, result;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
payload = jobs_1.ZSendEmailPayload.parse(rawPayload);
if (!(payload.kind === 'template')) return [3 /*break*/, 2];
return [
4 /*yield*/,
(0, email_1.sendEmail)(payload.template, {
to: payload.to,
// Re-validated by `sendEmail` against the registered template schema; the
// generic record was already validated in the API helper before enqueue.
fields: payload.fields,
from: payload.from,
replyTo: payload.replyTo
})
];
case 1:
responses_1 = _a.sent();
result_1 = extractProviderId(responses_1);
logger_1.log.info('email-sent', {
kind: 'template',
template: payload.template,
recipient: payload.to,
providerId: result_1.providerId
});
return [2 /*return*/, result_1];
case 2:
return [
4 /*yield*/,
(0, email_1.deliverEmail)([
{
to: payload.to,
subject: payload.subject,
content: payload.content,
from: payload.from,
replyTo: payload.replyTo
}
])
];
case 3:
responses = _a.sent();
result = extractProviderId(responses);
logger_1.log.info('email-sent', { kind: 'raw', recipient: payload.to, providerId: result.providerId });
return [2 /*return*/, result];
}
});
});
}
function extractProviderId(responses) {
var _a, _b, _c, _d;
var failed = responses.find(function (response) {
return !response.success;
});
if (failed) {
throw new Error(
(_a = failed.error) !== null && _a !== void 0 ? _a : 'email provider returned an unsuccessful response'
);
}
for (var _i = 0, responses_2 = responses; _i < responses_2.length; _i++) {
var response = responses_2[_i];
var details = response.details;
var candidate =
(_c =
(_b = details === null || details === void 0 ? void 0 : details.messageId) !== null && _b !== void 0
? _b
: details === null || details === void 0
? void 0
: details.request_id) !== null && _c !== void 0
? _c
: (_d = details === null || details === void 0 ? void 0 : details.data) === null || _d === void 0
? void 0
: _d.request_id;
if (candidate) {
return { providerId: String(candidate) };
}
}
return { providerId: '' };
}
@@ -1,309 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processExtractAudio = processExtractAudio;
var promises_1 = require('node:fs/promises');
var node_path_1 = require('node:path');
var node_os_1 = require('node:os');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'extract-audio';
var DOMAIN = 'media';
function transcodeToMp3(inputPath, outputPath) {
return (0, ffmpeg_1.ffmpegRun)([
'-y',
'-i',
inputPath,
'-vn',
'-acodec',
'libmp3lame',
'-b:a',
'32k',
'-ac',
'1',
'-ar',
'16000',
'-f',
'mp3',
outputPath
]);
}
/**
* Extract a 16 kHz mono MP3 (32 kbps) from the source video for Whisper (25 MB
* API limit). The transcribe step reads the object from the media bucket.
*/
function processExtractAudio(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
storageKey,
existing,
inputPath,
outputPath,
dir,
audioKey,
fileStat,
result,
error_1;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
payload = media_1.ZExtractAudioPayload.parse(rawData);
((mediaJobId = payload.mediaJobId), (assetId = payload.assetId), (storageKey = payload.storageKey));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _b.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('extract-audio-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'extracting-audio', progressPercent: 70 })
];
case 3:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_b.sent();
_b.label = 5;
case 5:
_b.trys.push([5, 14, 16, 19]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'audio-source.bin')
];
case 6:
inputPath = _b.sent();
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs', 'audio');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 7:
_b.sent();
outputPath = node_path_1.default.join(dir, ''.concat(mediaJobId, '.mp3'));
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_b.sent();
return [4 /*yield*/, transcodeToMp3(inputPath, outputPath)];
case 9:
_b.sent();
audioKey = 'audio/'.concat(assetId, '/').concat(mediaJobId, '.mp3');
return [
4 /*yield*/,
(0, storage_1.uploadFileToBucket)(
(0, storage_1.mediaBucket)(),
audioKey,
outputPath,
'audio/mpeg',
'private, max-age=0'
)
];
case 10:
_b.sent();
return [4 /*yield*/, (0, promises_1.stat)(outputPath)];
case 11:
fileStat = _b.sent();
result = { bucket: (0, storage_1.mediaBucket)(), key: audioKey, fileSizeBytes: fileStat.size };
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 12:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'audio-ready', progressPercent: 80 })
];
case 13:
_b.sent();
logger_1.log.info('extract-audio-done', { mediaJobId: mediaJobId, audioKey: audioKey });
return [2 /*return*/, result];
case 14:
error_1 = _b.sent();
logger_1.log.error('extract-audio-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'EXTRACT_AUDIO_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 15:
_b.sent();
throw error_1;
case 16:
return [4 /*yield*/, (0, storage_1.safeUnlink)(inputPath)];
case 17:
_b.sent();
return [4 /*yield*/, (0, storage_1.safeUnlink)(outputPath)];
case 18:
_b.sent();
return [7 /*endfinally*/];
case 19:
return [2 /*return*/];
}
});
});
}
@@ -1,338 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processGenerateThumbnail = processGenerateThumbnail;
var promises_1 = require('node:fs/promises');
var node_path_1 = require('node:path');
var node_os_1 = require('node:os');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var storage_2 = require('../../config/storage');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'generate-thumbnail';
var DOMAIN = 'media';
function extractThumbnail(localPath, atSeconds, outputPath) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [
4 /*yield*/,
(0, ffmpeg_1.ffmpegRun)([
'-y',
'-ss',
String(atSeconds),
'-i',
localPath,
'-vframes',
'1',
'-vf',
'scale=640:-1',
outputPath
])
];
case 1:
_a.sent();
return [2 /*return*/];
}
});
});
}
/**
* Render a single JPEG frame to use as the asset thumbnail. Replaces the
* browser-side thumbnail as the source of truth on the asset row.
*/
function processGenerateThumbnail(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
storageKey,
actorContext,
_a,
atSeconds,
existing,
localPath,
outputPath,
dir,
buffer,
thumbKey,
baseUrl,
publicUrl,
result,
error_1;
var _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
payload = media_1.ZGenerateThumbnailPayload.parse(rawData);
((mediaJobId = payload.mediaJobId),
(assetId = payload.assetId),
(storageKey = payload.storageKey),
(actorContext = payload.actorContext),
(_a = payload.atSeconds),
(atSeconds = _a === void 0 ? 1.0 : _a));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _c.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('generate-thumbnail-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'thumbnailing', progressPercent: 35 })
];
case 3:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_b = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _b !== void 0
? _b
: 0) + 1
})
];
case 4:
_c.sent();
_c.label = 5;
case 5:
_c.trys.push([5, 15, 17, 20]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'thumb-source.bin')
];
case 6:
localPath = _c.sent();
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs', 'thumbs');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 7:
_c.sent();
outputPath = node_path_1.default.join(dir, ''.concat(mediaJobId, '.jpg'));
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_c.sent();
return [4 /*yield*/, extractThumbnail(localPath, atSeconds, outputPath)];
case 9:
_c.sent();
return [4 /*yield*/, (0, promises_1.readFile)(outputPath)];
case 10:
buffer = _c.sent();
thumbKey = 'thumbnails/'.concat(assetId, '/').concat(Date.now(), '.jpg');
return [
4 /*yield*/,
(0, storage_1.uploadBufferToBucket)(
(0, storage_1.mediaBucket)(),
thumbKey,
buffer,
'image/jpeg',
'public, max-age=31536000'
)
];
case 11:
_c.sent();
baseUrl = (0, storage_2.getStorageConfig)().mediaPublicBaseUrl;
publicUrl = baseUrl ? ''.concat(baseUrl.replace(/\/$/, ''), '/').concat(thumbKey) : null;
result = { bucket: (0, storage_1.mediaBucket)(), key: thumbKey, publicUrl: publicUrl };
return [
4 /*yield*/,
(0, queries_1.updateAsset)(assetId, actorContext.organizationId, {
thumbnailUrl: publicUrl !== null && publicUrl !== void 0 ? publicUrl : undefined
})
];
case 12:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 13:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'thumbnailed', progressPercent: 60 })
];
case 14:
_c.sent();
logger_1.log.info('generate-thumbnail-done', { mediaJobId: mediaJobId, thumbKey: thumbKey });
return [2 /*return*/, result];
case 15:
error_1 = _c.sent();
logger_1.log.error('generate-thumbnail-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'THUMBNAIL_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 16:
_c.sent();
throw error_1;
case 17:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 18:
_c.sent();
return [4 /*yield*/, (0, storage_1.safeUnlink)(outputPath)];
case 19:
_c.sent();
return [7 /*endfinally*/];
case 20:
return [2 /*return*/];
}
});
});
}
@@ -27,6 +27,13 @@ type Slot = (typeof SLOT_ORDER)[number];
/** Fallback time offsets (seconds) tried when a slot's primary frame is black. */
const FALLBACK_OFFSETS = [0, -1.5, 1.5];
const DEFAULT_DURATION_SECONDS = 10;
/** Hard ceiling for a stored thumbnail. Frames over this are re-encoded at lower quality. */
const MAX_THUMBNAIL_BYTES = 500 * 1024;
/**
* mjpeg `-q:v` ladder (2 = best/largest, 31 = worst/smallest). We start at the
* top and step down only when a frame exceeds MAX_THUMBNAIL_BYTES.
*/
const QSCALE_LADDER = [2, 5, 9, 15] as const;
interface CandidateUpload {
slot: Slot;
@@ -58,7 +65,12 @@ function targetForSlot(slot: Slot, duration: number): number {
* keyframe and is the most common source of black thumbnails for codecs whose
* first keyframe sits past the requested offset.
*/
async function extractFrame(localPath: string, atSeconds: number, outputPath: string): Promise<void> {
async function extractFrame(
localPath: string,
atSeconds: number,
outputPath: string,
qscale: number = QSCALE_LADDER[0]
): Promise<void> {
await ffmpegRun([
'-y',
'-i',
@@ -68,11 +80,38 @@ async function extractFrame(localPath: string, atSeconds: number, outputPath: st
'-vframes',
'1',
'-vf',
'scale=640:-1',
"scale='min(1280,iw)':-2",
'-q:v',
String(qscale),
outputPath
]);
}
/**
* Re-encode the frame at `atSeconds` with progressively lower quality until it
* fits under MAX_THUMBNAIL_BYTES. Returns the smallest buffer produced if even
* the worst rung overshoots. Assumes the frame was already extracted once at
* QSCALE_LADDER[0].
*/
async function encodeUnderSizeCap(
localPath: string,
atSeconds: number,
outputPath: string,
buffer: Buffer
): Promise<Buffer> {
if (buffer.length <= MAX_THUMBNAIL_BYTES) return buffer;
let smallest = buffer;
for (const qscale of QSCALE_LADDER.slice(1)) {
await extractFrame(localPath, atSeconds, outputPath, qscale);
const reencoded = await readFile(outputPath);
if (reencoded.length < smallest.length) smallest = reencoded;
if (reencoded.length <= MAX_THUMBNAIL_BYTES) return reencoded;
}
return smallest;
}
async function captureSlot(
localPath: string,
slot: Slot,
@@ -86,7 +125,8 @@ async function captureSlot(
await extractFrame(localPath, t, outputPath);
const luma = await ffmpegProbeLuma(outputPath);
if (luma >= BLACK_LUMA_THRESHOLD) {
const buffer = await readFile(outputPath);
const raw = await readFile(outputPath);
const buffer = await encodeUnderSizeCap(localPath, t, outputPath, raw);
return { buffer, meanLuma: luma, atSeconds: t };
}
log.warn('generate-thumbnail-black-frame', { slot, atSeconds: t, luma });
-32
View File
@@ -1,32 +0,0 @@
'use strict';
var __createBinding =
(this && this.__createBinding) ||
(Object.create
? function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = {
enumerable: true,
get: function () {
return m[k];
}
};
}
Object.defineProperty(o, k2, desc);
}
: function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
});
var __exportStar =
(this && this.__exportStar) ||
function (m, exports) {
for (var p in m)
if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, '__esModule', { value: true });
__exportStar(require('./probe-metadata'), exports);
__exportStar(require('./generate-thumbnail'), exports);
__exportStar(require('./extract-audio'), exports);
__exportStar(require('./transcribe-audio'), exports);
@@ -1,311 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processProbeMetadata = processProbeMetadata;
var promises_1 = require('node:fs/promises');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'probe-metadata';
var DOMAIN = 'media';
function parseProbe(metadata, fileSizeBytes) {
var _a, _b;
var videoStream = metadata.streams.find(function (stream) {
return stream.codec_type === 'video';
});
var audioStream = metadata.streams.find(function (stream) {
return stream.codec_type === 'audio';
});
var duration = Number(
(_b =
(_a = metadata.format.duration) !== null && _a !== void 0
? _a
: videoStream === null || videoStream === void 0
? void 0
: videoStream.duration) !== null && _b !== void 0
? _b
: 0
);
return {
durationSeconds: Number.isFinite(duration) ? Math.max(0, Math.round(duration)) : 0,
width: videoStream === null || videoStream === void 0 ? void 0 : videoStream.width,
height: videoStream === null || videoStream === void 0 ? void 0 : videoStream.height,
videoCodec: videoStream === null || videoStream === void 0 ? void 0 : videoStream.codec_name,
audioCodec: audioStream === null || audioStream === void 0 ? void 0 : audioStream.codec_name,
hasAudio: Boolean(audioStream),
fileSizeBytes: fileSizeBytes
};
}
/**
* Probe authoritative metadata (duration, codecs, dimensions) using ffprobe.
* Replaces the browser-derived duration on the asset row.
*/
function processProbeMetadata(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload, mediaJobId, assetId, storageKey, actorContext, existing, localPath, fileStat, probe, result, error_1;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
payload = media_1.ZProbeMetadataPayload.parse(rawData);
((mediaJobId = payload.mediaJobId),
(assetId = payload.assetId),
(storageKey = payload.storageKey),
(actorContext = payload.actorContext));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _b.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('probe-metadata-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { status: 'running', stage: 'probing', progressPercent: 5 })
];
case 3:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_b.sent();
_b.label = 5;
case 5:
_b.trys.push([5, 13, 15, 17]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'source.bin')
];
case 6:
localPath = _b.sent();
return [4 /*yield*/, (0, promises_1.stat)(localPath)];
case 7:
fileStat = _b.sent();
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_b.sent();
return [4 /*yield*/, (0, ffmpeg_1.ffprobeJson)(localPath)];
case 9:
probe = _b.sent();
result = parseProbe(probe, fileStat.size);
return [
4 /*yield*/,
(0, queries_1.updateAsset)(assetId, actorContext.organizationId, {
durationSeconds: result.durationSeconds,
aspectRatio: result.width && result.height ? ''.concat(result.width, ':').concat(result.height) : null,
metadata: {
probe: {
durationSeconds: result.durationSeconds,
width: result.width,
height: result.height,
videoCodec: result.videoCodec,
audioCodec: result.audioCodec,
hasAudio: result.hasAudio,
fileSizeBytes: result.fileSizeBytes,
probedAt: new Date().toISOString()
}
}
})
];
case 10:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 11:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
stage: 'probed',
progressPercent: 25
})
];
case 12:
_b.sent();
logger_1.log.info('probe-metadata-done', { mediaJobId: mediaJobId, durationSeconds: result.durationSeconds });
return [2 /*return*/, result];
case 13:
error_1 = _b.sent();
logger_1.log.error('probe-metadata-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'PROBE_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 14:
_b.sent();
throw error_1;
case 15:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 16:
_b.sent();
return [7 /*endfinally*/];
case 17:
return [2 /*return*/];
}
});
});
}
@@ -1,538 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __spreadArray =
(this && this.__spreadArray) ||
function (to, from, pack) {
if (pack || arguments.length === 2)
for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processTranscribeAudio = processTranscribeAudio;
var promises_1 = require('node:fs/promises');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var env_1 = require('../../config/env');
var openai_1 = require('../../services/transcription/openai');
var vtt_1 = require('../../services/transcription/vtt');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'transcribe-audio';
var DOMAIN = 'media';
var EXTRACT_STEP_KEY = 'extract-audio';
function transcriptionCostCents(durationSeconds) {
return Math.ceil((durationSeconds / 60) * 0.6);
}
function mergeJobWarnings(mediaJobId, extra) {
return __awaiter(this, void 0, void 0, function () {
var job, prior;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
return [4 /*yield*/, (0, queries_1.getMediaJobById)(mediaJobId)];
case 1:
job = _b.sent();
prior = (_a = job === null || job === void 0 ? void 0 : job.warnings) !== null && _a !== void 0 ? _a : [];
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
warnings: __spreadArray(__spreadArray([], prior, true), extra, true)
})
];
case 2:
_b.sent();
return [2 /*return*/];
}
});
});
}
/**
* OpenAI Whisper (`whisper-1`) with verbose segment timestamps; persists VTT +
* `media_transcript` row. Skips cleanly when `OPENAI_API_KEY` is unset or when
* extracted audio exceeds the 25 MB API limit.
*/
function processTranscribeAudio(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
actorContext,
existing,
result,
extractStep,
extractResult,
message,
localPath,
localStat,
whisper,
normalizedSegments,
vttBody,
vttKey,
bucket,
transcriptCost,
jobRow,
priorCost,
result,
error_1,
error_2;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
payload = media_1.ZTranscribeAudioPayload.parse(rawData);
((mediaJobId = payload.mediaJobId), (assetId = payload.assetId), (actorContext = payload.actorContext));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _c.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('transcribe-audio-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'transcribing', progressPercent: 90 })
];
case 3:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_c.sent();
_c.label = 5;
case 5:
_c.trys.push([5, 35, , 37]);
if (!!env_1.env.OPENAI_API_KEY) return [3 /*break*/, 9];
result = { provider: 'skipped', text: '' };
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 6:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-no-openai-key'])];
case 7:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 8:
_c.sent();
logger_1.log.info('transcribe-audio-skipped', { mediaJobId: mediaJobId, reason: 'no-openai-key' });
return [2 /*return*/, result];
case 9:
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, EXTRACT_STEP_KEY)];
case 10:
extractStep = _c.sent();
extractResult = extractStep === null || extractStep === void 0 ? void 0 : extractStep.result;
if (
!(
!(extractResult === null || extractResult === void 0 ? void 0 : extractResult.bucket) ||
!(extractResult === null || extractResult === void 0 ? void 0 : extractResult.key)
)
)
return [3 /*break*/, 12];
message = 'extract-audio step missing or incomplete';
logger_1.log.error('transcribe-audio-failed', { mediaJobId: mediaJobId, error: message });
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'EXTRACT_AUDIO_MISSING', message: message }
})
];
case 11:
_c.sent();
throw new Error(message);
case 12:
localPath = void 0;
_c.label = 13;
case 13:
_c.trys.push([13, 27, 32, 34]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)(extractResult.bucket, extractResult.key, 'whisper-input.mp3')
];
case 14:
localPath = _c.sent();
return [4 /*yield*/, (0, promises_1.stat)(localPath)];
case 15:
localStat = _c.sent();
if (!(localStat.size > openai_1.WHISPER_MAX_FILE_BYTES)) return [3 /*break*/, 19];
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: {
code: 'AUDIO_TOO_LARGE',
message: 'Audio '.concat(localStat.size, ' bytes exceeds ').concat(openai_1.WHISPER_MAX_FILE_BYTES)
}
})
];
case 16:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-audio-too-large'])];
case 17:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 18:
_c.sent();
logger_1.log.warn('transcribe-audio-skipped-audio-too-large', {
mediaJobId: mediaJobId,
fileSizeBytes: localStat.size
});
return [2 /*return*/, { provider: 'skipped', text: '' }];
case 19:
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 20:
_c.sent();
return [
4 /*yield*/,
(0, openai_1.transcribeAudioFile)({
apiKey: env_1.env.OPENAI_API_KEY,
localPath: localPath
})
];
case 21:
whisper = _c.sent();
normalizedSegments = whisper.segments.map(function (segment) {
return {
start: segment.start,
end: segment.end,
text: segment.text
};
});
vttBody = (0, vtt_1.segmentsToWebVtt)(normalizedSegments);
vttKey = 'transcripts/'.concat(assetId, '/').concat(mediaJobId, '.vtt');
bucket = (0, storage_1.mediaBucket)();
return [
4 /*yield*/,
(0, storage_1.uploadBufferToBucket)(
bucket,
vttKey,
Buffer.from(vttBody, 'utf8'),
'text/vtt; charset=utf-8',
'private, max-age=0'
)
];
case 22:
_c.sent();
transcriptCost = transcriptionCostCents(whisper.durationSeconds);
return [4 /*yield*/, (0, queries_1.getMediaJobById)(mediaJobId)];
case 23:
jobRow = _c.sent();
priorCost =
(_b = jobRow === null || jobRow === void 0 ? void 0 : jobRow.costCents) !== null && _b !== void 0 ? _b : 0;
return [
4 /*yield*/,
(0, queries_1.upsertMediaTranscript)({
organizationId: actorContext.organizationId,
assetId: assetId,
mediaJobId: mediaJobId,
language: whisper.language,
provider: 'openai',
model: 'whisper-1',
text:
whisper.fullText ||
normalizedSegments
.map(function (segment) {
return segment.text;
})
.join(' ')
.trim(),
segments: normalizedSegments,
vttStorageKey: vttKey,
vttBucket: bucket,
durationSeconds: Math.round(whisper.durationSeconds),
costCents: transcriptCost
})
];
case 24:
_c.sent();
result = {
provider: 'openai',
text: whisper.fullText,
language: whisper.language,
durationSeconds: whisper.durationSeconds,
costCents: transcriptCost
};
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: {
language: whisper.language,
segmentCount: normalizedSegments.length,
vttKey: vttKey,
costCents: transcriptCost
},
providerId: null
})
];
case 25:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100,
costCents: priorCost + transcriptCost
})
];
case 26:
_c.sent();
logger_1.log.info('transcribe-audio-done', {
mediaJobId: mediaJobId,
language: whisper.language,
segmentCount: normalizedSegments.length
});
return [2 /*return*/, result];
case 27:
error_1 = _c.sent();
if (!(error_1 instanceof openai_1.AudioFileTooLargeError)) return [3 /*break*/, 31];
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'AUDIO_TOO_LARGE', message: error_1.message }
})
];
case 28:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-audio-too-large'])];
case 29:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 30:
_c.sent();
logger_1.log.warn('transcribe-audio-skipped-audio-too-large', {
mediaJobId: mediaJobId,
fileSizeBytes: error_1.fileSizeBytes
});
return [2 /*return*/, { provider: 'skipped', text: '' }];
case 31:
throw error_1;
case 32:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 33:
_c.sent();
return [7 /*endfinally*/];
case 34:
return [3 /*break*/, 37];
case 35:
error_2 = _c.sent();
logger_1.log.error('transcribe-audio-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_2)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'TRANSCRIBE_FAILED', message: (0, cancel_1.errorMessage)(error_2) }
})
];
case 36:
_c.sent();
throw error_2;
case 37:
return [2 /*return*/];
}
});
});
}
@@ -1,245 +0,0 @@
'use strict';
var __extends =
(this && this.__extends) ||
(function () {
var extendStatics = function (d, b) {
extendStatics =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (d, b) {
d.__proto__ = b;
}) ||
function (d, b) {
for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p];
};
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== 'function' && b !== null)
throw new TypeError('Class extends value ' + String(b) + ' is not a constructor or null');
extendStatics(d, b);
function __() {
this.constructor = d;
}
d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
};
})();
var __assign =
(this && this.__assign) ||
function () {
__assign =
Object.assign ||
function (t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.AudioFileTooLargeError = exports.WHISPER_MAX_FILE_BYTES = void 0;
exports.transcribeAudioFile = transcribeAudioFile;
var node_fs_1 = require('node:fs');
var promises_1 = require('node:fs/promises');
var openai_1 = require('openai');
/** OpenAI audio transcription file size limit (bytes). */
exports.WHISPER_MAX_FILE_BYTES = 25 * 1024 * 1024;
var AudioFileTooLargeError = /** @class */ (function (_super) {
__extends(AudioFileTooLargeError, _super);
function AudioFileTooLargeError(fileSizeBytes) {
var _this =
_super.call(
this,
'Audio file '.concat(fileSizeBytes, ' bytes exceeds Whisper limit of ').concat(exports.WHISPER_MAX_FILE_BYTES)
) || this;
_this.fileSizeBytes = fileSizeBytes;
_this.code = 'AUDIO_TOO_LARGE';
_this.name = 'AudioFileTooLargeError';
return _this;
}
return AudioFileTooLargeError;
})(Error);
exports.AudioFileTooLargeError = AudioFileTooLargeError;
/**
* Calls Whisper with verbose_json + segment timestamps. Omit `language` for
* auto-detection; the API returns the detected ISO 639-1 code.
*/
function transcribeAudioFile(params) {
return __awaiter(this, void 0, void 0, function () {
var fileStat, client, response, language, durationSeconds, fullText, rawSegments, segments;
var _a, _b, _c, _d, _e;
return __generator(this, function (_f) {
switch (_f.label) {
case 0:
return [4 /*yield*/, (0, promises_1.stat)(params.localPath)];
case 1:
fileStat = _f.sent();
if (fileStat.size > exports.WHISPER_MAX_FILE_BYTES) {
throw new AudioFileTooLargeError(fileStat.size);
}
client = new openai_1.default({ apiKey: params.apiKey });
return [
4 /*yield*/,
client.audio.transcriptions.create(
__assign(
{
file: (0, node_fs_1.createReadStream)(params.localPath),
model: 'whisper-1',
response_format: 'verbose_json',
timestamp_granularities: ['segment']
},
params.language ? { language: params.language } : {}
)
)
];
case 2:
response = _f.sent();
language = (_a = response.language) !== null && _a !== void 0 ? _a : 'und';
durationSeconds = (_b = response.duration) !== null && _b !== void 0 ? _b : 0;
fullText =
(_d = (_c = response.text) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0
? _d
: '';
rawSegments = (_e = response.segments) !== null && _e !== void 0 ? _e : [];
segments = rawSegments.map(function (seg) {
return {
start: seg.start,
end: seg.end,
text: seg.text.trim()
};
});
return [
2 /*return*/,
{ language: language, durationSeconds: durationSeconds, segments: segments, fullText: fullText }
];
}
});
});
}
@@ -1,37 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.segmentsToWebVtt = segmentsToWebVtt;
function formatVttTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) {
seconds = 0;
}
var totalMs = Math.round(seconds * 1000);
var hours = Math.floor(totalMs / 3600000);
var minutes = Math.floor((totalMs % 3600000) / 60000);
var secs = Math.floor((totalMs % 60000) / 1000);
var ms = totalMs % 1000;
var pad = function (n, width) {
return n.toString().padStart(width, '0');
};
return ''.concat(pad(hours, 2), ':').concat(pad(minutes, 2), ':').concat(pad(secs, 2), '.').concat(pad(ms, 3));
}
/**
* Build a minimal WEBVTT caption file from Whisper segment timestamps.
*/
function segmentsToWebVtt(segments) {
var lines = ['WEBVTT', ''];
var cueIndex = 1;
for (var _i = 0, segments_1 = segments; _i < segments_1.length; _i++) {
var segment = segments_1[_i];
var text = segment.text.trim();
if (!text) continue;
var start = formatVttTime(segment.start);
var end = formatVttTime(Math.max(segment.end, segment.start + 0.001));
lines.push(String(cueIndex));
lines.push(''.concat(start, ' --> ').concat(end));
lines.push(text);
lines.push('');
cueIndex++;
}
return lines.join('\n');
}
+1 -41
View File
@@ -1,41 +1 @@
import type { TranscriptionSegment } from './openai';
function formatVttTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
seconds = 0;
}
const totalMs = Math.round(seconds * 1000);
const hours = Math.floor(totalMs / 3_600_000);
const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
const secs = Math.floor((totalMs % 60_000) / 1000);
const ms = totalMs % 1000;
const pad = (n: number, width: number) => n.toString().padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(secs, 2)}.${pad(ms, 3)}`;
}
/**
* Build a minimal WEBVTT caption file from Whisper segment timestamps.
*/
export function segmentsToWebVtt(segments: TranscriptionSegment[]): string {
const lines = ['WEBVTT', ''];
let cueIndex = 1;
for (const segment of segments) {
const text = segment.text.trim();
if (!text) continue;
const start = formatVttTime(segment.start);
const end = formatVttTime(Math.max(segment.end, segment.start + 0.001));
lines.push(String(cueIndex));
lines.push(`${start} --> ${end}`);
lines.push(text);
lines.push('');
cueIndex++;
}
return lines.join('\n');
}
export { segmentsToWebVtt } from '@cio/utils/functions';
-214
View File
@@ -1,214 +0,0 @@
'use strict';
var __extends =
(this && this.__extends) ||
(function () {
var extendStatics = function (d, b) {
extendStatics =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (d, b) {
d.__proto__ = b;
}) ||
function (d, b) {
for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p];
};
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== 'function' && b !== null)
throw new TypeError('Class extends value ' + String(b) + ' is not a constructor or null');
extendStatics(d, b);
function __() {
this.constructor = d;
}
d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
};
})();
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.JobCanceledError = void 0;
exports.isMediaJobCancelRequested = isMediaJobCancelRequested;
exports.throwIfCancelRequested = throwIfCancelRequested;
exports.errorMessage = errorMessage;
var jobs_1 = require('@cio/db/queries/jobs');
var logger_1 = require('./logger');
/**
* Lightweight cooperative cancellation. Workers check `isCancelRequested` at
* step boundaries and inside long ffmpeg/fetch loops. The plan calls for
* `cancelRequestedAt` on each run table, with the worker abandoning further
* work the moment it observes a request.
*/
var JobCanceledError = /** @class */ (function (_super) {
__extends(JobCanceledError, _super);
function JobCanceledError(mediaJobId) {
var _this = _super.call(this, 'Media job '.concat(mediaJobId, ' canceled by user request')) || this;
_this.mediaJobId = mediaJobId;
_this.name = 'JobCanceledError';
return _this;
}
return JobCanceledError;
})(Error);
exports.JobCanceledError = JobCanceledError;
function isMediaJobCancelRequested(mediaJobId) {
return __awaiter(this, void 0, void 0, function () {
var job, error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, (0, jobs_1.getMediaJobById)(mediaJobId)];
case 1:
job = _a.sent();
return [2 /*return*/, Boolean(job === null || job === void 0 ? void 0 : job.cancelRequestedAt)];
case 2:
error_1 = _a.sent();
logger_1.log.warn('cancel-check-failed', { mediaJobId: mediaJobId, error: errorMessage(error_1) });
return [2 /*return*/, false];
case 3:
return [2 /*return*/];
}
});
});
}
function throwIfCancelRequested(mediaJobId) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, isMediaJobCancelRequested(mediaJobId)];
case 1:
if (_a.sent()) {
throw new JobCanceledError(mediaJobId);
}
return [2 /*return*/];
}
});
});
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
-250
View File
@@ -1,250 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var _a, _b;
Object.defineProperty(exports, '__esModule', { value: true });
exports.FFPROBE_BIN = exports.FFMPEG_BIN = void 0;
exports.ffprobeJson = ffprobeJson;
exports.ffmpegRun = ffmpegRun;
exports.warnIfFfmpegMissing = warnIfFfmpegMissing;
var node_child_process_1 = require('node:child_process');
var node_util_1 = require('node:util');
var logger_1 = require('./logger');
var execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
/**
* Resolved at module load. Set `FFMPEG_PATH` / `FFPROBE_PATH` to point at
* non-default binary locations (e.g. when ffmpeg lives outside PATH for the
* worker process). When unset we fall through to `ffmpeg` / `ffprobe` on
* PATH, which is what most installs provide.
*/
exports.FFMPEG_BIN = (_a = process.env.FFMPEG_PATH) !== null && _a !== void 0 ? _a : 'ffmpeg';
exports.FFPROBE_BIN = (_b = process.env.FFPROBE_PATH) !== null && _b !== void 0 ? _b : 'ffprobe';
/** Cap subprocess stdout/stderr so a runaway log can't blow up the worker. */
var MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
/**
* Run `ffprobe -print_format json -show_format -show_streams <file>` and
* return the parsed JSON. Mirrors the shape we used to get from
* `fluent-ffmpeg.ffprobe()` but without the deprecated wrapper.
*/
function ffprobeJson(localPath) {
return __awaiter(this, void 0, void 0, function () {
var args, stdout;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', localPath];
return [4 /*yield*/, execFileAsync(exports.FFPROBE_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
stdout = _a.sent().stdout;
return [2 /*return*/, JSON.parse(stdout)];
}
});
});
}
/**
* Spawn ffmpeg with the supplied argv. Captures stderr and surfaces a useful
* tail in the error message when ffmpeg exits non-zero, since ffmpeg writes
* progress and diagnostics to stderr by default.
*/
function ffmpegRun(args) {
return __awaiter(this, void 0, void 0, function () {
var error_1, stderr, tail;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
_c.trys.push([0, 2, , 3]);
return [4 /*yield*/, execFileAsync(exports.FFMPEG_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
_c.sent();
return [3 /*break*/, 3];
case 2:
error_1 = _c.sent();
stderr =
(_b =
(_a = error_1 === null || error_1 === void 0 ? void 0 : error_1.stderr) === null || _a === void 0
? void 0
: _a.toString('utf8')) !== null && _b !== void 0
? _b
: '';
tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
throw new Error('ffmpeg exited with error'.concat(tail ? ': '.concat(tail) : ''), { cause: error_1 });
case 3:
return [2 /*return*/];
}
});
});
}
/**
* Probe for the binaries at worker startup. Logs a warning when missing so
* media jobs surface a clearer failure than a raw ENOENT from the first
* spawn. Non-media workers (emails, maintenance) keep booting either way.
*/
function warnIfFfmpegMissing() {
return __awaiter(this, void 0, void 0, function () {
var checks, missing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, Promise.all([isExecutable(exports.FFMPEG_BIN), isExecutable(exports.FFPROBE_BIN)])];
case 1:
checks = _a.sent();
missing = [];
if (!checks[0]) missing.push(exports.FFMPEG_BIN);
if (!checks[1]) missing.push(exports.FFPROBE_BIN);
if (missing.length === 0) {
logger_1.log.info('ffmpeg-binaries-resolved', { ffmpeg: exports.FFMPEG_BIN, ffprobe: exports.FFPROBE_BIN });
return [2 /*return*/];
}
logger_1.log.warn('ffmpeg-binaries-missing', {
missing: missing,
hint: 'Install ffmpeg (macOS: brew install ffmpeg, Debian/Ubuntu: apt install ffmpeg) or set FFMPEG_PATH / FFPROBE_PATH. Media jobs will fail until this is resolved; other workers are unaffected.'
});
return [2 /*return*/];
}
});
});
}
function isExecutable(bin) {
return __awaiter(this, void 0, void 0, function () {
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_b.trys.push([0, 2, , 3]);
return [4 /*yield*/, execFileAsync(bin, ['-version'], { maxBuffer: MAX_OUTPUT_BYTES })];
case 1:
_b.sent();
return [2 /*return*/, true];
case 2:
_a = _b.sent();
return [2 /*return*/, false];
case 3:
return [2 /*return*/];
}
});
});
}
+29
View File
@@ -62,6 +62,35 @@ export async function ffmpegRun(args: string[]): Promise<void> {
}
}
/**
* Compute the mean luminance (`YAVG`) of an image or single video frame using
* ffmpeg's `signalstats` filter. Returned on the original 0-255 scale; a fully
* black frame is ~0, fully white ~255. Throws if ffmpeg fails or the value is
* not present in stderr.
*/
export async function ffmpegProbeLuma(filePath: string): Promise<number> {
const args = ['-hide_banner', '-nostats', '-i', filePath, '-vf', 'signalstats', '-f', 'null', '-'];
let stderr = '';
try {
const result = await execFileAsync(FFMPEG_BIN, args, { maxBuffer: MAX_OUTPUT_BYTES });
const raw = result.stderr as string | Buffer | undefined;
stderr = typeof raw === 'string' ? raw : (raw?.toString('utf8') ?? '');
} catch (error) {
const raw = (error as { stderr?: string | Buffer })?.stderr;
stderr = typeof raw === 'string' ? raw : (raw?.toString('utf8') ?? '');
if (!stderr) {
throw error as Error;
}
}
const match = stderr.match(/YAVG:([0-9]+(?:\.[0-9]+)?)/);
if (!match) {
throw new Error('ffmpegProbeLuma: signalstats YAVG not found in ffmpeg output');
}
return Number.parseFloat(match[1]);
}
/**
* Probe for the binaries at worker startup. Logs a warning when missing so
* media jobs surface a clearer failure than a raw ENOENT from the first
-39
View File
@@ -1,39 +0,0 @@
'use strict';
var __assign =
(this && this.__assign) ||
function () {
__assign =
Object.assign ||
function (t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.log = void 0;
function emit(level, message, fields) {
if (fields === void 0) {
fields = {};
}
var payload = __assign({ ts: new Date().toISOString(), level: level, msg: message }, fields);
var stream = level === 'error' || level === 'warn' ? console.error : console.log;
stream(JSON.stringify(payload));
}
exports.log = {
debug: function (msg, fields) {
return emit('debug', msg, fields);
},
info: function (msg, fields) {
return emit('info', msg, fields);
},
warn: function (msg, fields) {
return emit('warn', msg, fields);
},
error: function (msg, fields) {
return emit('error', msg, fields);
}
};
-266
View File
@@ -1,266 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.downloadObjectToTempFile = downloadObjectToTempFile;
exports.uploadFileToBucket = uploadFileToBucket;
exports.uploadBufferToBucket = uploadBufferToBucket;
exports.safeUnlink = safeUnlink;
exports.videosBucket = videosBucket;
exports.mediaBucket = mediaBucket;
var client_s3_1 = require('@aws-sdk/client-s3');
var node_fs_1 = require('node:fs');
var promises_1 = require('node:fs/promises');
var node_os_1 = require('node:os');
var node_path_1 = require('node:path');
var promises_2 = require('node:stream/promises');
var storage_1 = require('../config/storage');
/**
* Streams an S3 object to a temp file on disk and returns the absolute path.
* Workers prefer disk over buffers so ffmpeg can seek without holding the
* whole video in RAM.
*/
function downloadObjectToTempFile(bucket, key, fileNameHint) {
return __awaiter(this, void 0, void 0, function () {
var dir, safeName, localPath, response, body;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 1:
_b.sent();
safeName =
(_a =
fileNameHint === null || fileNameHint === void 0
? void 0
: fileNameHint.replace(/[^a-zA-Z0-9._-]/g, '_')) !== null && _a !== void 0
? _a
: 'media';
localPath = node_path_1.default.join(
dir,
''.concat(Date.now(), '_').concat(process.pid, '_').concat(safeName)
);
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key }))
];
case 2:
response = _b.sent();
if (!response.Body) {
throw new Error('S3 object '.concat(bucket, '/').concat(key, ' returned no body'));
}
body = response.Body;
return [4 /*yield*/, (0, promises_2.pipeline)(body, (0, node_fs_1.createWriteStream)(localPath))];
case 3:
_b.sent();
return [2 /*return*/, localPath];
}
});
});
}
function uploadFileToBucket(bucket, key, localPath, contentType, cacheControl) {
return __awaiter(this, void 0, void 0, function () {
var buffer;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [4 /*yield*/, (0, promises_1.readFile)(localPath)];
case 1:
buffer = _a.sent();
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(
new client_s3_1.PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
)
];
case 2:
_a.sent();
return [2 /*return*/];
}
});
});
}
function uploadBufferToBucket(bucket, key, buffer, contentType, cacheControl) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [
4 /*yield*/,
(0, storage_1.getS3Client)().send(
new client_s3_1.PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: cacheControl
})
)
];
case 1:
_a.sent();
return [2 /*return*/];
}
});
});
}
function safeUnlink(filePath) {
return __awaiter(this, void 0, void 0, function () {
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
if (!filePath) return [2 /*return*/];
_b.label = 1;
case 1:
_b.trys.push([1, 3, , 4]);
return [4 /*yield*/, (0, promises_1.unlink)(filePath)];
case 2:
_b.sent();
return [3 /*break*/, 4];
case 3:
_a = _b.sent();
return [3 /*break*/, 4];
case 4:
return [2 /*return*/];
}
});
});
}
function videosBucket() {
return (0, storage_1.getStorageConfig)().bucketVideos;
}
function mediaBucket() {
return (0, storage_1.getStorageConfig)().bucketMedia;
}
@@ -0,0 +1,82 @@
import './../bootstrap';
import { Worker } from 'bullmq';
import { recordDeadLetterJob } from '@cio/db/queries';
import { JOB_NAMES, QUEUE_NAMES, ZAgentCourseGenerationPayload, createRedisConnection } from '@cio/jobs';
import { runAgentCourseGenerationJob } from '@cio/core';
import { errorMessage } from '../utils/cancel';
import { env } from '../config/env';
import { log } from '../utils/logger';
const concurrency = Number.parseInt(env.AGENT_COURSE_GENERATION_WORKER_CONCURRENCY ?? '1', 10) || 1;
const connection = createRedisConnection();
const worker = new Worker(
QUEUE_NAMES.agentCourseGeneration,
async (job) => {
log.info('agent-course-generation-job-start', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade + 1
});
switch (job.name) {
case JOB_NAMES.agentCourseGeneration.run: {
const payload = ZAgentCourseGenerationPayload.parse(job.data);
return runAgentCourseGenerationJob({
runId: payload.runId,
bullmqJobId: job.id ?? null
});
}
default:
throw new Error(`Unknown agent course-generation job: ${job.name}`);
}
},
{ connection, concurrency }
);
worker.on('failed', async (job, err) => {
if (!job) return;
log.error('agent-course-generation-job-failed', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade,
error: errorMessage(err)
});
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? 1);
if (!isFinalAttempt) return;
const data = job.data as { runId?: string; organizationId?: string };
await recordDeadLetterJob({
organizationId: data?.organizationId ?? null,
domain: 'agent-course-generation',
runId: data?.runId ?? null,
queueName: QUEUE_NAMES.agentCourseGeneration,
jobName: job.name,
bullmqJobId: job.id ?? null,
payload: job.data as Record<string, unknown>,
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: errorMessage(err), stack: err.stack },
attempts: job.attemptsMade
});
});
worker.on('ready', () =>
log.info('agent-course-generation-worker-ready', { concurrency, queue: QUEUE_NAMES.agentCourseGeneration })
);
worker.on('error', (err) => log.error('agent-course-generation-worker-error', { error: errorMessage(err) }));
const shutdown = async (signal: string) => {
log.info('agent-course-generation-worker-shutdown', { signal });
await worker.close();
await connection.quit();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
-232
View File
@@ -1,232 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var _a;
Object.defineProperty(exports, '__esModule', { value: true });
require('./../bootstrap');
var bullmq_1 = require('bullmq');
var queries_1 = require('@cio/db/queries');
var jobs_1 = require('@cio/jobs');
var cancel_1 = require('../utils/cancel');
var env_1 = require('../config/env');
var logger_1 = require('../utils/logger');
var emails_1 = require('../processors/emails');
var concurrency =
Number.parseInt((_a = env_1.env.EMAIL_WORKER_CONCURRENCY) !== null && _a !== void 0 ? _a : '5', 10) || 5;
var connection = (0, jobs_1.createRedisConnection)();
var worker = new bullmq_1.Worker(
jobs_1.QUEUE_NAMES.emails,
function (job) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
logger_1.log.info('email-job-start', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade + 1
});
switch (job.name) {
case jobs_1.JOB_NAMES.emails.send:
return [2 /*return*/, (0, emails_1.processSendEmail)(job.data)];
default:
throw new Error('Unknown emails job: '.concat(job.name));
}
return [2 /*return*/];
});
});
},
{ connection: connection, concurrency: concurrency }
);
worker.on('failed', function (job, err) {
return __awaiter(void 0, void 0, void 0, function () {
var isFinalAttempt;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
if (!job) return [2 /*return*/];
logger_1.log.error('email-job-failed', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade,
error: (0, cancel_1.errorMessage)(err)
});
isFinalAttempt = job.attemptsMade >= ((_a = job.opts.attempts) !== null && _a !== void 0 ? _a : 1);
if (!isFinalAttempt) return [2 /*return*/];
// BullMQ exhausted retries — write a dead-letter row so an operator can
// inspect the payload + error and replay if needed.
return [
4 /*yield*/,
(0, queries_1.recordDeadLetterJob)({
organizationId: null,
domain: 'emails',
runId: null,
queueName: jobs_1.QUEUE_NAMES.emails,
jobName: job.name,
bullmqJobId: (_b = job.id) !== null && _b !== void 0 ? _b : null,
payload: job.data,
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: (0, cancel_1.errorMessage)(err), stack: err.stack },
attempts: job.attemptsMade
})
];
case 1:
// BullMQ exhausted retries — write a dead-letter row so an operator can
// inspect the payload + error and replay if needed.
_c.sent();
return [2 /*return*/];
}
});
});
});
worker.on('ready', function () {
return logger_1.log.info('email-worker-ready', { concurrency: concurrency, queue: jobs_1.QUEUE_NAMES.emails });
});
worker.on('error', function (err) {
return logger_1.log.error('email-worker-error', { error: (0, cancel_1.errorMessage)(err) });
});
var shutdown = function (signal) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
logger_1.log.info('email-worker-shutdown', { signal: signal });
return [4 /*yield*/, worker.close()];
case 1:
_a.sent();
return [4 /*yield*/, connection.quit()];
case 2:
_a.sent();
process.exit(0);
return [2 /*return*/];
}
});
});
};
process.on('SIGTERM', function () {
return void shutdown('SIGTERM');
});
process.on('SIGINT', function () {
return void shutdown('SIGINT');
});
-203
View File
@@ -1,203 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
require('./../bootstrap');
var bullmq_1 = require('bullmq');
var queries_1 = require('@cio/db/queries');
var jobs_1 = require('@cio/jobs');
var cancel_1 = require('../utils/cancel');
var logger_1 = require('../utils/logger');
var connection = (0, jobs_1.createRedisConnection)();
var worker = new bullmq_1.Worker(
jobs_1.QUEUE_NAMES.maintenance,
function (job) {
return __awaiter(void 0, void 0, void 0, function () {
var data, cutoff, deleted;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
if (!(job.name === jobs_1.JOB_NAMES.maintenance.deadLetterCleanup)) return [3 /*break*/, 2];
data = jobs_1.ZDeadLetterCleanupPayload.parse((_a = job.data) !== null && _a !== void 0 ? _a : {});
cutoff = new Date(Date.now() - data.olderThanDays * 86400 * 1000).toISOString();
return [4 /*yield*/, (0, queries_1.pruneDeadLetterJobsOlderThan)(cutoff)];
case 1:
deleted = _c.sent();
logger_1.log.info('dead-letter-cleanup-done', { deleted: deleted, cutoff: cutoff });
return [2 /*return*/, { deleted: deleted }];
case 2:
if (job.name === jobs_1.JOB_NAMES.maintenance.retentionCompact) {
jobs_1.ZRetentionCompactPayload.parse((_b = job.data) !== null && _b !== void 0 ? _b : {});
// Retention windows for media_job, job_step, email_delivery to be wired
// alongside their respective queries. Logged for now so the schedule
// is observable without silently dropping data.
logger_1.log.info('retention-compact-noop', { reason: 'not-yet-implemented' });
return [2 /*return*/, { compacted: 0 }];
}
throw new Error('Unknown maintenance job: '.concat(job.name));
}
});
});
},
{ connection: connection, concurrency: 1 }
);
worker.on('ready', function () {
return logger_1.log.info('maintenance-worker-ready');
});
worker.on('failed', function (job, err) {
return logger_1.log.error('maintenance-job-failed', {
jobName: job === null || job === void 0 ? void 0 : job.name,
error: (0, cancel_1.errorMessage)(err)
});
});
worker.on('error', function (err) {
return logger_1.log.error('maintenance-worker-error', { error: (0, cancel_1.errorMessage)(err) });
});
var shutdown = function (signal) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
logger_1.log.info('maintenance-worker-shutdown', { signal: signal });
return [4 /*yield*/, worker.close()];
case 1:
_a.sent();
return [4 /*yield*/, connection.quit()];
case 2:
_a.sent();
process.exit(0);
return [2 /*return*/];
}
});
});
};
process.on('SIGTERM', function () {
return void shutdown('SIGTERM');
});
process.on('SIGINT', function () {
return void shutdown('SIGINT');
});
+19
View File
@@ -2,10 +2,12 @@ import './../bootstrap';
import { Queue, Worker } from 'bullmq';
import { runAnalyticsRollupDaily } from '@cio/analytics';
import { pruneDeadLetterJobsOlderThan, reapStuckMediaJobs } from '@cio/db/queries';
import {
JOB_NAMES,
QUEUE_NAMES,
ZAnalyticsDailyRollupPayload,
ZDeadLetterCleanupPayload,
ZMediaJobReapPayload,
ZRetentionCompactPayload,
@@ -53,6 +55,13 @@ const worker = new Worker(
return { reaped: reaped.length };
}
if (job.name === JOB_NAMES.maintenance.analyticsDailyRollup) {
const data = ZAnalyticsDailyRollupPayload.parse(job.data ?? {});
const result = await runAnalyticsRollupDaily({ daysAgo: data.daysAgo });
log.info('analytics-rollup-done', result);
return result;
}
throw new Error(`Unknown maintenance job: ${job.name}`);
},
{ connection, concurrency: 1 }
@@ -80,6 +89,16 @@ async function registerSchedulers(): Promise<void> {
name: JOB_NAMES.maintenance.mediaJobReap,
everyMs: MEDIA_JOB_REAP_INTERVAL_MS
});
await maintenanceQueue.upsertJobScheduler(
'analytics-daily-rollup-scheduler',
{ every: 86_400_000 },
{ name: JOB_NAMES.maintenance.analyticsDailyRollup, data: {} }
);
log.info('analytics-rollup-scheduler-registered', {
name: JOB_NAMES.maintenance.analyticsDailyRollup,
everyMs: 86_400_000
});
} catch (err) {
log.error('maintenance-scheduler-register-failed', { error: errorMessage(err) });
}
-265
View File
@@ -1,265 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var _a;
Object.defineProperty(exports, '__esModule', { value: true });
require('./../bootstrap');
var bullmq_1 = require('bullmq');
var queries_1 = require('@cio/db/queries');
var jobs_1 = require('@cio/jobs');
var cancel_1 = require('../utils/cancel');
var env_1 = require('../config/env');
var logger_1 = require('../utils/logger');
var media_1 = require('../processors/media');
var concurrency =
Number.parseInt((_a = env_1.env.TRANSCRIBE_WORKER_CONCURRENCY) !== null && _a !== void 0 ? _a : '1', 10) || 1;
var connection = (0, jobs_1.createRedisConnection)();
var worker = new bullmq_1.Worker(
jobs_1.QUEUE_NAMES.mediaTranscribe,
function (job) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
logger_1.log.info('transcribe-job-start', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade + 1
});
if (job.name === jobs_1.JOB_NAMES.mediaTranscribe.transcribeAudio) {
return [2 /*return*/, (0, media_1.processTranscribeAudio)(job.data)];
}
throw new Error('Unknown transcribe job: '.concat(job.name));
});
});
},
{ connection: connection, concurrency: concurrency }
);
worker.on('failed', function (job, err) {
return __awaiter(void 0, void 0, void 0, function () {
var isFinalAttempt, data;
var _a, _b, _c, _d, _e;
return __generator(this, function (_f) {
switch (_f.label) {
case 0:
if (!job) return [2 /*return*/];
if (!(err instanceof cancel_1.JobCanceledError)) return [3 /*break*/, 2];
logger_1.log.warn('transcribe-job-canceled', { jobName: job.name, bullmqJobId: job.id });
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(err.mediaJobId, {
status: 'canceled',
stage: 'canceled',
error: { code: 'CANCELED', message: 'Run canceled by user' }
})
];
case 1:
_f.sent();
return [2 /*return*/];
case 2:
logger_1.log.error('transcribe-job-failed', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade,
error: (0, cancel_1.errorMessage)(err)
});
isFinalAttempt = job.attemptsMade >= ((_a = job.opts.attempts) !== null && _a !== void 0 ? _a : 1);
if (!isFinalAttempt) return [2 /*return*/];
data = job.data;
if (!(data === null || data === void 0 ? void 0 : data.mediaJobId)) return [3 /*break*/, 4];
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(data.mediaJobId, {
status: 'failed',
stage: 'failed',
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: (0, cancel_1.errorMessage)(err) }
})
];
case 3:
_f.sent();
_f.label = 4;
case 4:
return [
4 /*yield*/,
(0, queries_1.recordDeadLetterJob)({
organizationId:
(_c =
(_b = data === null || data === void 0 ? void 0 : data.actorContext) === null || _b === void 0
? void 0
: _b.organizationId) !== null && _c !== void 0
? _c
: null,
domain: 'media',
runId:
(_d = data === null || data === void 0 ? void 0 : data.mediaJobId) !== null && _d !== void 0
? _d
: null,
queueName: jobs_1.QUEUE_NAMES.mediaTranscribe,
jobName: job.name,
bullmqJobId: (_e = job.id) !== null && _e !== void 0 ? _e : null,
payload: job.data,
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: (0, cancel_1.errorMessage)(err), stack: err.stack },
attempts: job.attemptsMade
})
];
case 5:
_f.sent();
return [2 /*return*/];
}
});
});
});
worker.on('ready', function () {
return logger_1.log.info('transcribe-worker-ready', {
concurrency: concurrency,
queue: jobs_1.QUEUE_NAMES.mediaTranscribe
});
});
worker.on('error', function (err) {
return logger_1.log.error('transcribe-worker-error', { error: (0, cancel_1.errorMessage)(err) });
});
var shutdown = function (signal) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
logger_1.log.info('transcribe-worker-shutdown', { signal: signal });
return [4 /*yield*/, worker.close()];
case 1:
_a.sent();
return [4 /*yield*/, connection.quit()];
case 2:
_a.sent();
process.exit(0);
return [2 /*return*/];
}
});
});
};
process.on('SIGTERM', function () {
return void shutdown('SIGTERM');
});
process.on('SIGINT', function () {
return void shutdown('SIGINT');
});
-265
View File
@@ -1,265 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var _a;
Object.defineProperty(exports, '__esModule', { value: true });
require('./../bootstrap');
var bullmq_1 = require('bullmq');
var queries_1 = require('@cio/db/queries');
var jobs_1 = require('@cio/jobs');
var cancel_1 = require('../utils/cancel');
var env_1 = require('../config/env');
var logger_1 = require('../utils/logger');
var media_1 = require('../processors/media');
var concurrency =
Number.parseInt((_a = env_1.env.MEDIA_WORKER_CONCURRENCY) !== null && _a !== void 0 ? _a : '2', 10) || 2;
var connection = (0, jobs_1.createRedisConnection)();
var worker = new bullmq_1.Worker(
jobs_1.QUEUE_NAMES.media,
function (job) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
logger_1.log.info('media-job-start', { jobName: job.name, bullmqJobId: job.id, attempt: job.attemptsMade + 1 });
switch (job.name) {
case jobs_1.JOB_NAMES.media.probeMetadata:
return [2 /*return*/, (0, media_1.processProbeMetadata)(job.data)];
case jobs_1.JOB_NAMES.media.generateThumbnail:
return [2 /*return*/, (0, media_1.processGenerateThumbnail)(job.data)];
case jobs_1.JOB_NAMES.media.extractAudio:
return [2 /*return*/, (0, media_1.processExtractAudio)(job.data)];
default:
throw new Error('Unknown media job: '.concat(job.name));
}
return [2 /*return*/];
});
});
},
{ connection: connection, concurrency: concurrency }
);
worker.on('failed', function (job, err) {
return __awaiter(void 0, void 0, void 0, function () {
var isFinalAttempt, data;
var _a, _b, _c, _d, _e;
return __generator(this, function (_f) {
switch (_f.label) {
case 0:
if (!job) return [2 /*return*/];
if (!(err instanceof cancel_1.JobCanceledError)) return [3 /*break*/, 2];
logger_1.log.warn('media-job-canceled', { jobName: job.name, bullmqJobId: job.id });
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(err.mediaJobId, {
status: 'canceled',
stage: 'canceled',
error: { code: 'CANCELED', message: 'Run canceled by user' }
})
];
case 1:
_f.sent();
return [2 /*return*/];
case 2:
logger_1.log.error('media-job-failed', {
jobName: job.name,
bullmqJobId: job.id,
attempt: job.attemptsMade,
error: (0, cancel_1.errorMessage)(err)
});
isFinalAttempt = job.attemptsMade >= ((_a = job.opts.attempts) !== null && _a !== void 0 ? _a : 1);
if (!isFinalAttempt) return [2 /*return*/];
data = job.data;
if (!(data === null || data === void 0 ? void 0 : data.mediaJobId)) return [3 /*break*/, 4];
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(data.mediaJobId, {
status: 'failed',
stage: 'failed',
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: (0, cancel_1.errorMessage)(err) }
})
];
case 3:
_f.sent();
_f.label = 4;
case 4:
return [
4 /*yield*/,
(0, queries_1.recordDeadLetterJob)({
organizationId:
(_c =
(_b = data === null || data === void 0 ? void 0 : data.actorContext) === null || _b === void 0
? void 0
: _b.organizationId) !== null && _c !== void 0
? _c
: null,
domain: 'media',
runId:
(_d = data === null || data === void 0 ? void 0 : data.mediaJobId) !== null && _d !== void 0
? _d
: null,
queueName: jobs_1.QUEUE_NAMES.media,
jobName: job.name,
bullmqJobId: (_e = job.id) !== null && _e !== void 0 ? _e : null,
payload: job.data,
error: { code: 'WORKER_EXHAUSTED_RETRIES', message: (0, cancel_1.errorMessage)(err), stack: err.stack },
attempts: job.attemptsMade
})
];
case 5:
_f.sent();
return [2 /*return*/];
}
});
});
});
worker.on('ready', function () {
return logger_1.log.info('media-worker-ready', { concurrency: concurrency, queue: jobs_1.QUEUE_NAMES.media });
});
worker.on('error', function (err) {
return logger_1.log.error('media-worker-error', { error: (0, cancel_1.errorMessage)(err) });
});
var shutdown = function (signal) {
return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
logger_1.log.info('media-worker-shutdown', { signal: signal });
return [4 /*yield*/, worker.close()];
case 1:
_a.sent();
return [4 /*yield*/, connection.quit()];
case 2:
_a.sent();
process.exit(0);
return [2 /*return*/];
}
});
});
};
process.on('SIGTERM', function () {
return void shutdown('SIGTERM');
});
process.on('SIGINT', function () {
return void shutdown('SIGINT');
});