@frkntmbs/strapi-plugin-video-optimizer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/admin/custom.d.ts +8 -0
- package/admin/src/buildVersion.ts +3 -0
- package/admin/src/components/AssetOptimizationLabel.tsx +61 -0
- package/admin/src/components/BridgeProviders.tsx +123 -0
- package/admin/src/components/MediaLibraryCacheBridge.tsx +24 -0
- package/admin/src/components/MediaLibraryCardActionsBridge.tsx +249 -0
- package/admin/src/components/MediaLibraryJobWatcher.tsx +136 -0
- package/admin/src/components/MediaLibraryProgressBridge.tsx +97 -0
- package/admin/src/components/OptimizationChoicePicker.tsx +65 -0
- package/admin/src/components/OptimizationResizeFields.tsx +120 -0
- package/admin/src/components/OptimizationVideoFields.tsx +217 -0
- package/admin/src/components/UploadEnhancerBridge.tsx +205 -0
- package/admin/src/components/upload/PendingAssetStep.tsx +97 -0
- package/admin/src/defaultGlobalSettings.ts +32 -0
- package/admin/src/hooks/useDefaultOptimizationMode.ts +24 -0
- package/admin/src/hooks/useUploadWithOptimizer.ts +45 -0
- package/admin/src/index.ts +84 -0
- package/admin/src/pages/SettingsPage.tsx +208 -0
- package/admin/src/pluginId.ts +79 -0
- package/admin/src/translations/en.json +74 -0
- package/admin/src/translations/tr.json +74 -0
- package/admin/src/utils/adminFetch.ts +57 -0
- package/admin/src/utils/captureQueryClient.ts +34 -0
- package/admin/src/utils/debugMediaLibraryProgress.ts +70 -0
- package/admin/src/utils/extractAssetDimensions.ts +22 -0
- package/admin/src/utils/initJobPoller.ts +173 -0
- package/admin/src/utils/initMediaLibraryCardActions.ts +308 -0
- package/admin/src/utils/initMediaLibraryProgress.ts +219 -0
- package/admin/src/utils/initUploadEnhancer.ts +447 -0
- package/admin/src/utils/invalidateMediaLibrary.ts +203 -0
- package/admin/src/utils/jobProgressStore.ts +113 -0
- package/admin/src/utils/mediaLibraryCardMatch.ts +414 -0
- package/admin/src/utils/mediaLibraryCardStore.ts +223 -0
- package/admin/src/utils/mediaLibraryQueryBridge.ts +113 -0
- package/admin/src/utils/mediaLibraryRoute.ts +9 -0
- package/admin/src/utils/optimizationFields.ts +17 -0
- package/admin/src/utils/probeVideoDimensions.ts +94 -0
- package/admin/src/utils/uploadAssetStore.ts +670 -0
- package/admin/tsconfig.json +8 -0
- package/dist/admin/SettingsPage-CN2fR83m.js +150 -0
- package/dist/admin/SettingsPage-D6e536P0.mjs +150 -0
- package/dist/admin/en-CqM903j3.js +77 -0
- package/dist/admin/en-CsHicGzL.mjs +77 -0
- package/dist/admin/index-BjWoS0YU.js +2542 -0
- package/dist/admin/index-Cs_uiChW.mjs +2541 -0
- package/dist/admin/index-DOuHOS2G.js +8799 -0
- package/dist/admin/index-rAmxCQz6.mjs +8781 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/tr-Y0-ANilh.mjs +77 -0
- package/dist/admin/tr-muzHkdC4.js +77 -0
- package/dist/server/index.js +1538 -0
- package/dist/server/index.mjs +1533 -0
- package/package.json +100 -0
- package/server/index.js +1 -0
- package/server/src/bootstrap.ts +377 -0
- package/server/src/buildVersion.ts +1 -0
- package/server/src/config/defaults.ts +91 -0
- package/server/src/config/index.ts +51 -0
- package/server/src/constants.ts +83 -0
- package/server/src/controllers/index.ts +7 -0
- package/server/src/controllers/job.ts +102 -0
- package/server/src/controllers/preference.ts +206 -0
- package/server/src/index.ts +15 -0
- package/server/src/register.ts +19 -0
- package/server/src/routes/index.ts +103 -0
- package/server/src/services/index.ts +9 -0
- package/server/src/services/job-queue.ts +663 -0
- package/server/src/services/optimizer.ts +284 -0
- package/server/src/services/preference.ts +172 -0
- package/server/src/utils/request-context.ts +7 -0
- package/server/src/utils/upload-preferences-context.ts +202 -0
- package/server/tsconfig.json +8 -0
- package/strapi-admin.js +7 -0
- package/strapi-server.js +7 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import type { Core } from '@strapi/strapi';
|
|
5
|
+
import {
|
|
6
|
+
PLUGIN_ID,
|
|
7
|
+
type OptimizationChoice,
|
|
8
|
+
type OptimizationSettings,
|
|
9
|
+
type VideoOptimizerJob,
|
|
10
|
+
} from '../constants';
|
|
11
|
+
|
|
12
|
+
const FILE_MODEL_UID = 'plugin::upload.file';
|
|
13
|
+
|
|
14
|
+
interface EnqueuePayload {
|
|
15
|
+
fileId: number;
|
|
16
|
+
settings: OptimizationSettings;
|
|
17
|
+
choice?: OptimizationChoice;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StoredJobs {
|
|
21
|
+
byId: Record<string, VideoOptimizerJob>;
|
|
22
|
+
byFileId: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emptyStore = (): StoredJobs => ({ byId: {}, byFileId: {} });
|
|
26
|
+
|
|
27
|
+
const now = () => new Date().toISOString();
|
|
28
|
+
|
|
29
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => {
|
|
30
|
+
const activeJobs = new Set<string>();
|
|
31
|
+
const cancelledJobIds = new Set<string>();
|
|
32
|
+
let draining = false;
|
|
33
|
+
|
|
34
|
+
const getPreferenceService = () => strapi.plugin(PLUGIN_ID).service('preference');
|
|
35
|
+
const getOptimizerService = () => strapi.plugin(PLUGIN_ID).service('optimizer');
|
|
36
|
+
|
|
37
|
+
const loadStore = async (): Promise<StoredJobs> => {
|
|
38
|
+
const stored = await getPreferenceService().getJobsStore().get<StoredJobs>();
|
|
39
|
+
return stored ?? emptyStore();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const saveStore = async (store: StoredJobs) => {
|
|
43
|
+
await getPreferenceService().getJobsStore().set({ value: store });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const updateJob = async (
|
|
47
|
+
jobId: string,
|
|
48
|
+
patch: Partial<VideoOptimizerJob>
|
|
49
|
+
): Promise<VideoOptimizerJob | null> => {
|
|
50
|
+
const store = await loadStore();
|
|
51
|
+
const current = store.byId[jobId];
|
|
52
|
+
|
|
53
|
+
if (!current) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const next: VideoOptimizerJob = {
|
|
58
|
+
...current,
|
|
59
|
+
...patch,
|
|
60
|
+
updatedAt: now(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
activeJobs.has(jobId) &&
|
|
65
|
+
patch.status !== 'completed' &&
|
|
66
|
+
patch.status !== 'failed'
|
|
67
|
+
) {
|
|
68
|
+
next.status = 'processing';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
store.byId[jobId] = next;
|
|
72
|
+
await saveStore(store);
|
|
73
|
+
return next;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const removeStaleJobsForFile = (store: StoredJobs, fileId: number, keepJobId?: string) => {
|
|
77
|
+
for (const [jobId, job] of Object.entries(store.byId)) {
|
|
78
|
+
if (job.fileId !== fileId) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (jobId === keepJobId) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (activeJobs.has(jobId)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
delete store.byId[jobId];
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isCancellationError = (jobId: string, error: unknown) => {
|
|
95
|
+
if (cancelledJobIds.has(jobId)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!(error instanceof Error)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return /SIGKILL|SIGTERM|ffmpeg was killed|cancelled|canceled/i.test(error.message);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const cancelJobsForFile = async (fileId: number) => {
|
|
107
|
+
const store = await loadStore();
|
|
108
|
+
const matchingJobs = Object.values(store.byId).filter((job) => job.fileId === fileId);
|
|
109
|
+
|
|
110
|
+
if (!matchingJobs.length) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const job of matchingJobs) {
|
|
115
|
+
cancelledJobIds.add(job.id);
|
|
116
|
+
activeJobs.delete(job.id);
|
|
117
|
+
getOptimizerService().cancel(job.id);
|
|
118
|
+
delete store.byId[job.id];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
delete store.byFileId[String(fileId)];
|
|
122
|
+
await saveStore(store);
|
|
123
|
+
|
|
124
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
125
|
+
where: { id: fileId },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (file) {
|
|
129
|
+
const providerMetadata = {
|
|
130
|
+
...((file.provider_metadata as Record<string, unknown> | undefined) ?? {}),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
delete providerMetadata.videoOptimizer;
|
|
134
|
+
|
|
135
|
+
await strapi.db.query('plugin::upload.file').update({
|
|
136
|
+
where: { id: fileId },
|
|
137
|
+
data: {
|
|
138
|
+
provider_metadata: providerMetadata,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
strapi.log.info(`[video-optimizer] Cancelled ${matchingJobs.length} job(s) for file ${fileId}`);
|
|
144
|
+
|
|
145
|
+
void drainQueue();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const removeJobFromStore = async (jobId: string, fileId?: number) => {
|
|
149
|
+
const store = await loadStore();
|
|
150
|
+
|
|
151
|
+
delete store.byId[jobId];
|
|
152
|
+
|
|
153
|
+
if (fileId !== undefined && store.byFileId[String(fileId)] === jobId) {
|
|
154
|
+
delete store.byFileId[String(fileId)];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await saveStore(store);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const getMaxConcurrentJobs = async () => {
|
|
161
|
+
const settings = await getPreferenceService().getGlobalSettings();
|
|
162
|
+
return settings.maxConcurrentJobs;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const resolveInputPath = async (fileId: number): Promise<string | null> => {
|
|
166
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
167
|
+
where: { id: fileId },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!file?.url) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const uploadConfig = strapi.config.get('plugin::upload') as { provider?: string };
|
|
175
|
+
const provider = uploadConfig?.provider ?? 'local';
|
|
176
|
+
|
|
177
|
+
if (provider === 'local') {
|
|
178
|
+
const publicDir = strapi.dirs.static.public;
|
|
179
|
+
const relativePath = String(file.url).replace(/^\//, '');
|
|
180
|
+
const absolutePath = `${publicDir}/${relativePath}`;
|
|
181
|
+
|
|
182
|
+
if (fs.existsSync(absolutePath)) {
|
|
183
|
+
return absolutePath;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const uploadFolder = strapi.dirs.static.public;
|
|
188
|
+
const uploadsPath = `${uploadFolder}/uploads/${file.hash}${file.ext}`;
|
|
189
|
+
|
|
190
|
+
if (fs.existsSync(uploadsPath)) {
|
|
191
|
+
return uploadsPath;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const applyOptimizedFile = async (
|
|
198
|
+
fileId: number,
|
|
199
|
+
result: Awaited<ReturnType<ReturnType<typeof getOptimizerService>['process']>>
|
|
200
|
+
) => {
|
|
201
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
202
|
+
where: { id: fileId },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!file) {
|
|
206
|
+
throw new Error(`File ${fileId} not found`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const publicDir = strapi.dirs.static.public;
|
|
210
|
+
const oldRelativePath = String(file.url).replace(/^\//, '');
|
|
211
|
+
const oldAbsolutePath = path.join(publicDir, oldRelativePath);
|
|
212
|
+
const baseName = String(file.name).replace(/\.[^.]+$/, '');
|
|
213
|
+
const newRelativePath = `/uploads/${file.hash}${result.ext}`;
|
|
214
|
+
const newAbsolutePath = path.join(publicDir, newRelativePath.replace(/^\//, ''));
|
|
215
|
+
const originalSizeInBytes = Number(
|
|
216
|
+
file.sizeInBytes ?? (typeof file.size === 'number' ? Math.round(file.size * 1024) : 0)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (oldAbsolutePath !== newAbsolutePath && fs.existsSync(oldAbsolutePath)) {
|
|
220
|
+
fs.unlinkSync(oldAbsolutePath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fs.mkdirSync(path.dirname(newAbsolutePath), { recursive: true });
|
|
224
|
+
fs.copyFileSync(result.outputPath, newAbsolutePath);
|
|
225
|
+
|
|
226
|
+
const updatePayload = {
|
|
227
|
+
name: `${baseName}${result.ext}`,
|
|
228
|
+
ext: result.ext,
|
|
229
|
+
mime: result.mime,
|
|
230
|
+
url: newRelativePath,
|
|
231
|
+
size: getOptimizerService().bytesToKbytes(result.sizeInBytes),
|
|
232
|
+
sizeInBytes: result.sizeInBytes,
|
|
233
|
+
width: result.width ?? file.width,
|
|
234
|
+
height: result.height ?? file.height,
|
|
235
|
+
provider_metadata: {
|
|
236
|
+
...(file.provider_metadata ?? {}),
|
|
237
|
+
videoOptimizer: {
|
|
238
|
+
status: 'completed',
|
|
239
|
+
optimizedAt: now(),
|
|
240
|
+
originalSizeInBytes,
|
|
241
|
+
optimizedSizeInBytes: result.sizeInBytes,
|
|
242
|
+
format: result.ext.replace('.', ''),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const updated = await strapi.db.query(FILE_MODEL_UID).update({
|
|
248
|
+
where: { id: fileId },
|
|
249
|
+
data: updatePayload,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
strapi.eventHub.emit('media.update', { media: updated });
|
|
253
|
+
|
|
254
|
+
strapi.log.info(
|
|
255
|
+
`[video-optimizer] File ${fileId} updated in Media Library (${Math.round(originalSizeInBytes / 1024 / 1024)}MB → ${Math.round(result.sizeInBytes / 1024 / 1024)}MB, ${result.ext})`
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const getTargetExt = (job: VideoOptimizerJob) =>
|
|
260
|
+
job.settings?.defaultFormat === 'webm' ? '.webm' : '.mp4';
|
|
261
|
+
|
|
262
|
+
const getOptimizerMeta = (file: Record<string, unknown>) =>
|
|
263
|
+
(file.provider_metadata as { videoOptimizer?: { status?: string; choice?: string } } | undefined)
|
|
264
|
+
?.videoOptimizer;
|
|
265
|
+
|
|
266
|
+
const runJob = async (jobId: string) => {
|
|
267
|
+
if (!activeJobs.has(jobId)) {
|
|
268
|
+
activeJobs.add(jobId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let fileId: number | undefined;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const store = await loadStore();
|
|
275
|
+
const job = store.byId[jobId];
|
|
276
|
+
|
|
277
|
+
if (!job) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fileId = job.fileId;
|
|
282
|
+
|
|
283
|
+
strapi.log.info(`[video-optimizer] Job ${jobId} started for file ${job.fileId}`);
|
|
284
|
+
await updateJob(jobId, { status: 'processing', stage: 'preparing', progress: 0 });
|
|
285
|
+
|
|
286
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
287
|
+
where: { id: job.fileId },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!file) {
|
|
291
|
+
throw new Error('Uploaded file not found');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const settings = job.settings;
|
|
295
|
+
|
|
296
|
+
if (!settings) {
|
|
297
|
+
await updateJob(jobId, {
|
|
298
|
+
status: 'completed',
|
|
299
|
+
stage: 'skipped',
|
|
300
|
+
progress: 100,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const optimizerMeta = getOptimizerMeta(file);
|
|
306
|
+
const resizeMode =
|
|
307
|
+
optimizerMeta?.choice === 'global' ? 'fit-within' : 'exact';
|
|
308
|
+
|
|
309
|
+
const inputPath = await resolveInputPath(job.fileId);
|
|
310
|
+
|
|
311
|
+
if (!inputPath) {
|
|
312
|
+
throw new Error('Could not resolve uploaded file path');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
strapi.log.info(
|
|
316
|
+
`[video-optimizer] Job ${jobId} encoding file ${job.fileId} as ${settings.defaultFormat} (input: ${inputPath})`
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
let lastLoggedProgress = -1;
|
|
320
|
+
|
|
321
|
+
const result = await getOptimizerService().process({
|
|
322
|
+
jobId,
|
|
323
|
+
inputPath,
|
|
324
|
+
settings,
|
|
325
|
+
resizeMode,
|
|
326
|
+
onProgress: (progress, stage) => {
|
|
327
|
+
void updateJob(jobId, { status: 'processing', progress, stage });
|
|
328
|
+
|
|
329
|
+
if (progress >= lastLoggedProgress + 10 || progress >= 95) {
|
|
330
|
+
lastLoggedProgress = progress;
|
|
331
|
+
strapi.log.info(
|
|
332
|
+
`[video-optimizer] Job ${jobId} progress ${progress}% (${stage}, ${settings.defaultFormat})`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await applyOptimizedFile(job.fileId, result);
|
|
339
|
+
await getOptimizerService().cleanup(result.outputPath);
|
|
340
|
+
|
|
341
|
+
await updateJob(jobId, {
|
|
342
|
+
status: 'completed',
|
|
343
|
+
stage: 'completed',
|
|
344
|
+
progress: 100,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await removeJobFromStore(jobId, job.fileId);
|
|
348
|
+
|
|
349
|
+
strapi.log.info(
|
|
350
|
+
`[video-optimizer] Job ${jobId} completed for file ${job.fileId} → ${result.ext} (${result.sizeInBytes} bytes)`
|
|
351
|
+
);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (isCancellationError(jobId, error)) {
|
|
354
|
+
cancelledJobIds.delete(jobId);
|
|
355
|
+
await removeJobFromStore(jobId, fileId);
|
|
356
|
+
strapi.log.info(
|
|
357
|
+
`[video-optimizer] Job ${jobId} cancelled${fileId ? ` for file ${fileId}` : ''}`
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const message = error instanceof Error ? error.message : 'Video optimization failed';
|
|
363
|
+
|
|
364
|
+
await updateJob(jobId, {
|
|
365
|
+
status: 'failed',
|
|
366
|
+
stage: 'failed',
|
|
367
|
+
progress: 0,
|
|
368
|
+
error: message,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (fileId) {
|
|
372
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
373
|
+
where: { id: fileId },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (file) {
|
|
377
|
+
await strapi.db.query('plugin::upload.file').update({
|
|
378
|
+
where: { id: fileId },
|
|
379
|
+
data: {
|
|
380
|
+
provider_metadata: {
|
|
381
|
+
...(file.provider_metadata ?? {}),
|
|
382
|
+
videoOptimizer: {
|
|
383
|
+
status: 'failed',
|
|
384
|
+
error: message,
|
|
385
|
+
failedAt: now(),
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
strapi.log.error(`[video-optimizer] Job ${jobId} failed: ${message}`);
|
|
394
|
+
} finally {
|
|
395
|
+
activeJobs.delete(jobId);
|
|
396
|
+
void drainQueue();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const drainQueue = async () => {
|
|
401
|
+
if (draining) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
draining = true;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const maxConcurrent = await getMaxConcurrentJobs();
|
|
409
|
+
const store = await loadStore();
|
|
410
|
+
const queued = Object.values(store.byId)
|
|
411
|
+
.filter((job) => job.status === 'queued')
|
|
412
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
413
|
+
|
|
414
|
+
for (const job of queued) {
|
|
415
|
+
if (activeJobs.size >= maxConcurrent) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (activeJobs.has(job.id)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
activeJobs.add(job.id);
|
|
424
|
+
void runJob(job.id);
|
|
425
|
+
}
|
|
426
|
+
} finally {
|
|
427
|
+
draining = false;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
async enqueue({ fileId, settings, choice }: EnqueuePayload): Promise<VideoOptimizerJob> {
|
|
433
|
+
const store = await loadStore();
|
|
434
|
+
const existingId = store.byFileId[String(fileId)];
|
|
435
|
+
const existingJob = existingId ? store.byId[existingId] : undefined;
|
|
436
|
+
|
|
437
|
+
if (
|
|
438
|
+
existingJob?.status === 'processing' &&
|
|
439
|
+
activeJobs.has(existingId!)
|
|
440
|
+
) {
|
|
441
|
+
return existingJob;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
removeStaleJobsForFile(store, fileId);
|
|
445
|
+
|
|
446
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
447
|
+
where: { id: fileId },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const job: VideoOptimizerJob = {
|
|
451
|
+
id: randomUUID(),
|
|
452
|
+
fileId,
|
|
453
|
+
fileName: file?.name ? String(file.name) : undefined,
|
|
454
|
+
fileHash: file?.hash ? String(file.hash) : undefined,
|
|
455
|
+
status: 'queued',
|
|
456
|
+
stage: 'queued',
|
|
457
|
+
progress: 0,
|
|
458
|
+
settings,
|
|
459
|
+
createdAt: now(),
|
|
460
|
+
updatedAt: now(),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
store.byId[job.id] = job;
|
|
464
|
+
store.byFileId[String(fileId)] = job.id;
|
|
465
|
+
await saveStore(store);
|
|
466
|
+
|
|
467
|
+
strapi.log.info(
|
|
468
|
+
`[video-optimizer] Job ${job.id} queued for file ${fileId} (${settings.defaultFormat}, crf=${settings.crf})`
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
if (file) {
|
|
472
|
+
await strapi.db.query('plugin::upload.file').update({
|
|
473
|
+
where: { id: fileId },
|
|
474
|
+
data: {
|
|
475
|
+
provider_metadata: {
|
|
476
|
+
...(file.provider_metadata ?? {}),
|
|
477
|
+
videoOptimizer: {
|
|
478
|
+
status: 'queued',
|
|
479
|
+
jobId: job.id,
|
|
480
|
+
queuedAt: now(),
|
|
481
|
+
...(choice ? { choice } : {}),
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
void drainQueue();
|
|
489
|
+
return job;
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
async getJob(jobId: string): Promise<VideoOptimizerJob | null> {
|
|
493
|
+
const store = await loadStore();
|
|
494
|
+
return store.byId[jobId] ?? null;
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
async getJobsByFileIds(fileIds: number[]): Promise<VideoOptimizerJob[]> {
|
|
498
|
+
const store = await loadStore();
|
|
499
|
+
const jobs: VideoOptimizerJob[] = [];
|
|
500
|
+
|
|
501
|
+
for (const fileId of fileIds) {
|
|
502
|
+
const jobId = store.byFileId[String(fileId)];
|
|
503
|
+
|
|
504
|
+
if (jobId && store.byId[jobId]) {
|
|
505
|
+
jobs.push(store.byId[jobId]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return jobs;
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async listActiveJobs(): Promise<VideoOptimizerJob[]> {
|
|
513
|
+
const store = await loadStore();
|
|
514
|
+
const result: VideoOptimizerJob[] = [];
|
|
515
|
+
let dirty = false;
|
|
516
|
+
|
|
517
|
+
for (const job of Object.values(store.byId)) {
|
|
518
|
+
if (job.status === 'completed' || job.status === 'failed') {
|
|
519
|
+
delete store.byId[job.id];
|
|
520
|
+
|
|
521
|
+
if (store.byFileId[String(job.fileId)] === job.id) {
|
|
522
|
+
delete store.byFileId[String(job.fileId)];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
dirty = true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const job of Object.values(store.byId)) {
|
|
530
|
+
if (job.status !== 'queued' && job.status !== 'processing') {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const canonicalJobId = store.byFileId[String(job.fileId)];
|
|
535
|
+
const isRunning = activeJobs.has(job.id);
|
|
536
|
+
const isCanonical = canonicalJobId === job.id;
|
|
537
|
+
|
|
538
|
+
if (!isCanonical && !isRunning) {
|
|
539
|
+
delete store.byId[job.id];
|
|
540
|
+
dirty = true;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (isRunning && !isCanonical) {
|
|
545
|
+
store.byFileId[String(job.fileId)] = job.id;
|
|
546
|
+
|
|
547
|
+
if (canonicalJobId && canonicalJobId !== job.id && !activeJobs.has(canonicalJobId)) {
|
|
548
|
+
delete store.byId[canonicalJobId];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
dirty = true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
555
|
+
where: { id: job.fileId },
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
if (!file) {
|
|
559
|
+
await cancelJobsForFile(job.fileId);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const optimizerMeta = getOptimizerMeta(file as Record<string, unknown>);
|
|
564
|
+
|
|
565
|
+
if (optimizerMeta?.status === 'completed' || optimizerMeta?.status === 'failed') {
|
|
566
|
+
delete store.byId[job.id];
|
|
567
|
+
|
|
568
|
+
if (store.byFileId[String(job.fileId)] === job.id) {
|
|
569
|
+
delete store.byFileId[String(job.fileId)];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
dirty = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let current = store.byId[job.id] ?? job;
|
|
577
|
+
|
|
578
|
+
if (!current.fileName && file.name) {
|
|
579
|
+
current = {
|
|
580
|
+
...current,
|
|
581
|
+
fileName: String(file.name),
|
|
582
|
+
updatedAt: now(),
|
|
583
|
+
};
|
|
584
|
+
store.byId[job.id] = current;
|
|
585
|
+
dirty = true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!current.fileHash && file.hash) {
|
|
589
|
+
current = {
|
|
590
|
+
...current,
|
|
591
|
+
fileHash: String(file.hash),
|
|
592
|
+
updatedAt: now(),
|
|
593
|
+
};
|
|
594
|
+
store.byId[job.id] = current;
|
|
595
|
+
dirty = true;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (isRunning && current.status !== 'processing') {
|
|
599
|
+
current = {
|
|
600
|
+
...current,
|
|
601
|
+
status: 'processing',
|
|
602
|
+
updatedAt: now(),
|
|
603
|
+
};
|
|
604
|
+
store.byId[job.id] = current;
|
|
605
|
+
dirty = true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (current.status === 'processing' && !isRunning) {
|
|
609
|
+
if (optimizerMeta?.status === 'completed' || optimizerMeta?.status === 'failed') {
|
|
610
|
+
delete store.byId[job.id];
|
|
611
|
+
|
|
612
|
+
if (store.byFileId[String(job.fileId)] === job.id) {
|
|
613
|
+
delete store.byFileId[String(job.fileId)];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
dirty = true;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const targetExt = getTargetExt(current);
|
|
621
|
+
|
|
622
|
+
if (file.ext === targetExt) {
|
|
623
|
+
delete store.byId[job.id];
|
|
624
|
+
|
|
625
|
+
if (store.byFileId[String(job.fileId)] === job.id) {
|
|
626
|
+
delete store.byFileId[String(job.fileId)];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
dirty = true;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
result.push(store.byId[job.id] ?? current);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (dirty) {
|
|
638
|
+
await saveStore(store);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return result;
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
async clearJobsOnStartup() {
|
|
645
|
+
const store = await loadStore();
|
|
646
|
+
const jobCount = Object.keys(store.byId).length;
|
|
647
|
+
|
|
648
|
+
if (jobCount === 0) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await saveStore(emptyStore());
|
|
653
|
+
|
|
654
|
+
strapi.log.info(
|
|
655
|
+
`[video-optimizer] Cleared ${jobCount} persisted job(s) on startup (jobs are not resumed after restart)`
|
|
656
|
+
);
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
drainQueue,
|
|
660
|
+
|
|
661
|
+
cancelJobsForFile,
|
|
662
|
+
};
|
|
663
|
+
};
|