@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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +286 -0
  3. package/admin/custom.d.ts +8 -0
  4. package/admin/src/buildVersion.ts +3 -0
  5. package/admin/src/components/AssetOptimizationLabel.tsx +61 -0
  6. package/admin/src/components/BridgeProviders.tsx +123 -0
  7. package/admin/src/components/MediaLibraryCacheBridge.tsx +24 -0
  8. package/admin/src/components/MediaLibraryCardActionsBridge.tsx +249 -0
  9. package/admin/src/components/MediaLibraryJobWatcher.tsx +136 -0
  10. package/admin/src/components/MediaLibraryProgressBridge.tsx +97 -0
  11. package/admin/src/components/OptimizationChoicePicker.tsx +65 -0
  12. package/admin/src/components/OptimizationResizeFields.tsx +120 -0
  13. package/admin/src/components/OptimizationVideoFields.tsx +217 -0
  14. package/admin/src/components/UploadEnhancerBridge.tsx +205 -0
  15. package/admin/src/components/upload/PendingAssetStep.tsx +97 -0
  16. package/admin/src/defaultGlobalSettings.ts +32 -0
  17. package/admin/src/hooks/useDefaultOptimizationMode.ts +24 -0
  18. package/admin/src/hooks/useUploadWithOptimizer.ts +45 -0
  19. package/admin/src/index.ts +84 -0
  20. package/admin/src/pages/SettingsPage.tsx +208 -0
  21. package/admin/src/pluginId.ts +79 -0
  22. package/admin/src/translations/en.json +74 -0
  23. package/admin/src/translations/tr.json +74 -0
  24. package/admin/src/utils/adminFetch.ts +57 -0
  25. package/admin/src/utils/captureQueryClient.ts +34 -0
  26. package/admin/src/utils/debugMediaLibraryProgress.ts +70 -0
  27. package/admin/src/utils/extractAssetDimensions.ts +22 -0
  28. package/admin/src/utils/initJobPoller.ts +173 -0
  29. package/admin/src/utils/initMediaLibraryCardActions.ts +308 -0
  30. package/admin/src/utils/initMediaLibraryProgress.ts +219 -0
  31. package/admin/src/utils/initUploadEnhancer.ts +447 -0
  32. package/admin/src/utils/invalidateMediaLibrary.ts +203 -0
  33. package/admin/src/utils/jobProgressStore.ts +113 -0
  34. package/admin/src/utils/mediaLibraryCardMatch.ts +414 -0
  35. package/admin/src/utils/mediaLibraryCardStore.ts +223 -0
  36. package/admin/src/utils/mediaLibraryQueryBridge.ts +113 -0
  37. package/admin/src/utils/mediaLibraryRoute.ts +9 -0
  38. package/admin/src/utils/optimizationFields.ts +17 -0
  39. package/admin/src/utils/probeVideoDimensions.ts +94 -0
  40. package/admin/src/utils/uploadAssetStore.ts +670 -0
  41. package/admin/tsconfig.json +8 -0
  42. package/dist/admin/SettingsPage-CN2fR83m.js +150 -0
  43. package/dist/admin/SettingsPage-D6e536P0.mjs +150 -0
  44. package/dist/admin/en-CqM903j3.js +77 -0
  45. package/dist/admin/en-CsHicGzL.mjs +77 -0
  46. package/dist/admin/index-BjWoS0YU.js +2542 -0
  47. package/dist/admin/index-Cs_uiChW.mjs +2541 -0
  48. package/dist/admin/index-DOuHOS2G.js +8799 -0
  49. package/dist/admin/index-rAmxCQz6.mjs +8781 -0
  50. package/dist/admin/index.js +4 -0
  51. package/dist/admin/index.mjs +4 -0
  52. package/dist/admin/tr-Y0-ANilh.mjs +77 -0
  53. package/dist/admin/tr-muzHkdC4.js +77 -0
  54. package/dist/server/index.js +1538 -0
  55. package/dist/server/index.mjs +1533 -0
  56. package/package.json +100 -0
  57. package/server/index.js +1 -0
  58. package/server/src/bootstrap.ts +377 -0
  59. package/server/src/buildVersion.ts +1 -0
  60. package/server/src/config/defaults.ts +91 -0
  61. package/server/src/config/index.ts +51 -0
  62. package/server/src/constants.ts +83 -0
  63. package/server/src/controllers/index.ts +7 -0
  64. package/server/src/controllers/job.ts +102 -0
  65. package/server/src/controllers/preference.ts +206 -0
  66. package/server/src/index.ts +15 -0
  67. package/server/src/register.ts +19 -0
  68. package/server/src/routes/index.ts +103 -0
  69. package/server/src/services/index.ts +9 -0
  70. package/server/src/services/job-queue.ts +663 -0
  71. package/server/src/services/optimizer.ts +284 -0
  72. package/server/src/services/preference.ts +172 -0
  73. package/server/src/utils/request-context.ts +7 -0
  74. package/server/src/utils/upload-preferences-context.ts +202 -0
  75. package/server/tsconfig.json +8 -0
  76. package/strapi-admin.js +7 -0
  77. package/strapi-server.js +7 -0
@@ -0,0 +1,203 @@
1
+ import type { Dispatch } from '@reduxjs/toolkit';
2
+ import type { QueryClient } from 'react-query';
3
+ import { adminApi } from '@strapi/strapi/admin';
4
+ import { adminGet } from './adminFetch';
5
+ import { syncMediaLibraryProgress } from './initMediaLibraryProgress';
6
+ import { findCardForFile } from './mediaLibraryCardMatch';
7
+ import { getWatchedJobs } from './jobProgressStore';
8
+ import {
9
+ registerMediaLibraryQueryClientBridge,
10
+ invalidateFetchedUploadAssets,
11
+ type UploadAssetRecord,
12
+ } from './mediaLibraryQueryBridge';
13
+
14
+ type AppDispatch = Dispatch;
15
+
16
+ const UPLOAD_PLUGIN_ID = 'upload';
17
+
18
+ let dispatchRef: AppDispatch | null = null;
19
+ let queryClientRef: QueryClient | null = null;
20
+ const pendingInvalidations: Array<number | undefined> = [];
21
+
22
+ type UploadFileRecord = UploadAssetRecord;
23
+
24
+ const flushPendingInvalidations = () => {
25
+ if (!dispatchRef && !queryClientRef) {
26
+ return;
27
+ }
28
+
29
+ for (const fileId of pendingInvalidations.splice(0)) {
30
+ void invalidateMediaLibraryCache(fileId);
31
+ }
32
+ };
33
+
34
+ export const registerMediaLibraryDispatch = (dispatch: AppDispatch | null) => {
35
+ dispatchRef = dispatch;
36
+
37
+ if (!dispatchRef) {
38
+ return;
39
+ }
40
+
41
+ flushPendingInvalidations();
42
+ };
43
+
44
+ export const registerMediaLibraryQueryClient = (queryClient: QueryClient | null) => {
45
+ queryClientRef = queryClient;
46
+ registerMediaLibraryQueryClientBridge(queryClient);
47
+
48
+ if (!queryClientRef) {
49
+ return;
50
+ }
51
+
52
+ flushPendingInvalidations();
53
+ };
54
+
55
+ const hasActiveEncodingJobs = () =>
56
+ getWatchedJobs().some(
57
+ (job) => job.status === 'queued' || job.status === 'processing'
58
+ );
59
+
60
+ const invalidateReactQueryAssets = async () => {
61
+ if (!queryClientRef) {
62
+ return;
63
+ }
64
+
65
+ await queryClientRef.invalidateQueries([UPLOAD_PLUGIN_ID, 'assets']);
66
+ await queryClientRef.invalidateQueries([UPLOAD_PLUGIN_ID, 'folders']);
67
+ await queryClientRef.invalidateQueries([UPLOAD_PLUGIN_ID, 'asset-count']);
68
+ await queryClientRef.refetchQueries([UPLOAD_PLUGIN_ID, 'assets'], {
69
+ active: true,
70
+ });
71
+ };
72
+
73
+ const invalidateRtkAssets = (fileId?: number) => {
74
+ if (!dispatchRef) {
75
+ return;
76
+ }
77
+
78
+ dispatchRef(
79
+ adminApi.util.invalidateTags([
80
+ { type: 'Asset', id: 'LIST' },
81
+ ...(fileId ? [{ type: 'Asset' as const, id: fileId }] : []),
82
+ { type: 'Folder', id: 'LIST' },
83
+ ])
84
+ );
85
+ };
86
+
87
+ const findAssetCard = (file: UploadFileRecord) => findCardForFile(file);
88
+
89
+ const toAssetUrl = (url?: string) => {
90
+ if (!url) {
91
+ return undefined;
92
+ }
93
+
94
+ return url.startsWith('/') ? `${window.strapi.backendURL}${url}` : url;
95
+ };
96
+
97
+ const updateCardFromFile = (card: Element, file: UploadFileRecord) => {
98
+ const fileName = file.name ?? '';
99
+ const extLabel = (file.ext ?? '').replace(/^\./, '').toLowerCase();
100
+ const assetUrl = toAssetUrl(file.url);
101
+
102
+ for (const titleNode of card.querySelectorAll('[id$="-title"]')) {
103
+ if (fileName) {
104
+ titleNode.textContent = fileName;
105
+ }
106
+ }
107
+
108
+ const figcaption = card.querySelector('figcaption');
109
+
110
+ if (figcaption && fileName) {
111
+ figcaption.textContent = fileName;
112
+ }
113
+
114
+ if (extLabel) {
115
+ for (const node of card.querySelectorAll('span, p, div')) {
116
+ const text = node.textContent?.trim().toLowerCase();
117
+
118
+ if (text === 'mp4' || text === 'webm' || text === 'mov' || text === 'avi' || text === 'mkv') {
119
+ if (node.childElementCount === 0) {
120
+ node.textContent = extLabel;
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ if (assetUrl) {
127
+ const video = card.querySelector('video');
128
+
129
+ if (video) {
130
+ video.src = assetUrl;
131
+ video.load();
132
+ }
133
+
134
+ for (const source of card.querySelectorAll('video source')) {
135
+ source.setAttribute('src', assetUrl);
136
+ }
137
+ }
138
+
139
+ const hasActiveJob = getWatchedJobs().some(
140
+ (job) =>
141
+ job.fileId === file.id &&
142
+ (job.status === 'queued' || job.status === 'processing')
143
+ );
144
+
145
+ if (!hasActiveJob) {
146
+ card.querySelector('[data-video-optimizer-progress-host]')?.remove();
147
+ }
148
+
149
+ if (file.hash) {
150
+ (card as HTMLElement).dataset.videoOptimizerMediaHash = file.hash;
151
+ }
152
+
153
+ (card as HTMLElement).dataset.videoOptimizerFileId = String(file.id);
154
+ };
155
+
156
+ export const refreshAssetCardInDom = async (fileId: number, previousFileName?: string) => {
157
+ const file = await adminGet<UploadFileRecord>(`/upload/files/${fileId}`);
158
+
159
+ if (!file) {
160
+ return;
161
+ }
162
+
163
+ const card = findAssetCard(file);
164
+
165
+ if (!card) {
166
+ return;
167
+ }
168
+
169
+ updateCardFromFile(card, file);
170
+ syncMediaLibraryProgress();
171
+ };
172
+
173
+ export const invalidateMediaLibraryCache = async (
174
+ fileId?: number,
175
+ previousFileName?: string,
176
+ options?: { forceFullRefresh?: boolean }
177
+ ) => {
178
+ if (!dispatchRef && !queryClientRef) {
179
+ pendingInvalidations.push(fileId);
180
+ return;
181
+ }
182
+
183
+ const shouldSkipFullRefresh = hasActiveEncodingJobs() && !options?.forceFullRefresh;
184
+
185
+ if (shouldSkipFullRefresh) {
186
+ if (fileId) {
187
+ await refreshAssetCardInDom(fileId, previousFileName);
188
+ }
189
+
190
+ syncMediaLibraryProgress();
191
+ return;
192
+ }
193
+
194
+ invalidateFetchedUploadAssets();
195
+ invalidateRtkAssets(fileId);
196
+ await invalidateReactQueryAssets();
197
+
198
+ if (fileId) {
199
+ await refreshAssetCardInDom(fileId, previousFileName);
200
+ }
201
+
202
+ syncMediaLibraryProgress();
203
+ };
@@ -0,0 +1,113 @@
1
+ import type { VideoOptimizerJob } from '../pluginId';
2
+
3
+ export interface ProgressEntry {
4
+ fileId: number;
5
+ host: HTMLElement;
6
+ job: VideoOptimizerJob;
7
+ }
8
+
9
+ let entries: ProgressEntry[] = [];
10
+ let entriesSnapshot: ProgressEntry[] = [];
11
+ let watchedJobs: VideoOptimizerJob[] = [];
12
+ let watchedJobsSnapshot: VideoOptimizerJob[] = [];
13
+ let activeFileIdsSnapshot: readonly number[] = [];
14
+ let activeFileIdsKey = '';
15
+ const listeners = new Set<() => void>();
16
+
17
+ const rebuildActiveFileIdsSnapshot = () => {
18
+ const ids = watchedJobsSnapshot
19
+ .filter((job) => job.status === 'queued' || job.status === 'processing')
20
+ .map((job) => job.fileId)
21
+ .sort((left, right) => left - right);
22
+ const key = ids.join(',');
23
+
24
+ if (key === activeFileIdsKey) {
25
+ return;
26
+ }
27
+
28
+ activeFileIdsKey = key;
29
+ activeFileIdsSnapshot = ids;
30
+ };
31
+
32
+ const notify = () => {
33
+ listeners.forEach((listener) => listener());
34
+ };
35
+
36
+ export const subscribeJobProgress = (listener: () => void) => {
37
+ listeners.add(listener);
38
+ return () => listeners.delete(listener);
39
+ };
40
+
41
+ export const getProgressEntries = () => entriesSnapshot;
42
+
43
+ export const getWatchedJobs = () => watchedJobsSnapshot;
44
+
45
+ export const getActiveJobFileIds = (): readonly number[] => {
46
+ rebuildActiveFileIdsSnapshot();
47
+ return activeFileIdsSnapshot;
48
+ };
49
+
50
+ export const hasActiveJobForFile = (fileId: number) => getActiveJobFileIds().includes(fileId);
51
+
52
+ const jobsEqual = (left: VideoOptimizerJob[], right: VideoOptimizerJob[]) => {
53
+ if (left.length !== right.length) {
54
+ return false;
55
+ }
56
+
57
+ return left.every(
58
+ (job, index) =>
59
+ job.id === right[index]?.id &&
60
+ job.status === right[index]?.status &&
61
+ job.progress === right[index]?.progress &&
62
+ job.stage === right[index]?.stage &&
63
+ job.error === right[index]?.error
64
+ );
65
+ };
66
+
67
+ export const setWatchedJobs = (nextJobs: VideoOptimizerJob[]) => {
68
+ if (jobsEqual(watchedJobs, nextJobs)) {
69
+ return;
70
+ }
71
+
72
+ watchedJobs = nextJobs;
73
+ watchedJobsSnapshot = nextJobs.slice();
74
+ rebuildActiveFileIdsSnapshot();
75
+ notify();
76
+ };
77
+
78
+ const entriesEqual = (left: ProgressEntry[], right: ProgressEntry[]) => {
79
+ if (left.length !== right.length) {
80
+ return false;
81
+ }
82
+
83
+ return left.every(
84
+ (entry, index) =>
85
+ entry.fileId === right[index]?.fileId &&
86
+ entry.host === right[index]?.host &&
87
+ entry.job.id === right[index]?.job.id &&
88
+ entry.job.status === right[index]?.job.status &&
89
+ entry.job.progress === right[index]?.job.progress &&
90
+ entry.job.stage === right[index]?.job.stage &&
91
+ entry.job.error === right[index]?.job.error
92
+ );
93
+ };
94
+
95
+ export const setProgressEntries = (nextEntries: ProgressEntry[]) => {
96
+ if (entriesEqual(entries, nextEntries)) {
97
+ return;
98
+ }
99
+
100
+ entries = nextEntries;
101
+ entriesSnapshot = nextEntries.slice();
102
+ notify();
103
+ };
104
+
105
+ export const clearProgressEntries = () => {
106
+ entries = [];
107
+ entriesSnapshot = [];
108
+ watchedJobs = [];
109
+ watchedJobsSnapshot = [];
110
+ activeFileIdsKey = '';
111
+ activeFileIdsSnapshot = [];
112
+ notify();
113
+ };
@@ -0,0 +1,414 @@
1
+ import type { VideoOptimizerJob } from '../pluginId';
2
+ import {
3
+ getUploadAssetsFromCache,
4
+ type UploadAssetRecord,
5
+ } from './mediaLibraryQueryBridge';
6
+
7
+ const CARD_SELECTOR = [
8
+ 'article[role="button"]',
9
+ 'article[aria-labelledby]',
10
+ '[role="listitem"]',
11
+ '[class*="CardContainer"]',
12
+ ].join(', ');
13
+
14
+ const VIDEO_EXT_PATTERN =
15
+ /\.(mp4|webm|mov|avi|mkv|m4v|ogv|wmv|flv|3gp)/i;
16
+
17
+ const watchedCards = new WeakSet<Element>();
18
+ const jobCardRefs = new Map<number, Element>();
19
+
20
+ export const extractMediaUrlFromCard = (card: Element): string | null => {
21
+ const video = card.querySelector('video');
22
+
23
+ if (video?.src && !video.src.startsWith('blob:')) {
24
+ return video.src;
25
+ }
26
+
27
+ const source = video?.querySelector('source');
28
+
29
+ if (source?.src && !source.src.startsWith('blob:')) {
30
+ return source.src;
31
+ }
32
+
33
+ return null;
34
+ };
35
+
36
+ export const extractFileHashFromUrl = (url: string): string | null => {
37
+ try {
38
+ const pathname = new URL(url, window.location.origin).pathname;
39
+ const filename = decodeURIComponent(pathname.split('/').pop() ?? '');
40
+ const match = filename.match(/_([a-z0-9]+)\.[^.]+$/i);
41
+
42
+ return match?.[1] ?? null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ export const extractFileNameFromCard = (card: Element): string | null => {
49
+ const titleNode = card.querySelector('[id$="-title"]');
50
+
51
+ if (titleNode?.textContent?.trim()) {
52
+ return titleNode.textContent.trim();
53
+ }
54
+
55
+ const figcaption = card.querySelector('figcaption');
56
+
57
+ if (figcaption?.textContent?.trim()) {
58
+ return figcaption.textContent.trim();
59
+ }
60
+
61
+ return null;
62
+ };
63
+
64
+ const cacheCardMediaHash = (card: Element, hash: string) => {
65
+ (card as HTMLElement).dataset.videoOptimizerMediaHash = hash;
66
+ };
67
+
68
+ export const extractFileHashFromCard = (card: Element): string | null => {
69
+ const htmlCard = card as HTMLElement;
70
+ const cached = htmlCard.dataset.videoOptimizerMediaHash;
71
+
72
+ if (cached) {
73
+ return cached;
74
+ }
75
+
76
+ const url = extractMediaUrlFromCard(card);
77
+
78
+ if (!url) {
79
+ return null;
80
+ }
81
+
82
+ const hash = extractFileHashFromUrl(url);
83
+
84
+ if (hash) {
85
+ cacheCardMediaHash(card, hash);
86
+ }
87
+
88
+ return hash;
89
+ };
90
+
91
+ export const ensureCardMediaHash = (card: Element, onReady?: () => void) => {
92
+ const existing = extractFileHashFromCard(card);
93
+
94
+ if (existing) {
95
+ cacheCardMediaHash(card, existing);
96
+ return existing;
97
+ }
98
+
99
+ if (watchedCards.has(card)) {
100
+ return null;
101
+ }
102
+
103
+ watchedCards.add(card);
104
+
105
+ const video = card.querySelector('video');
106
+
107
+ if (!video) {
108
+ return null;
109
+ }
110
+
111
+ const capture = () => {
112
+ const url = video.src;
113
+
114
+ if (!url || url.startsWith('blob:')) {
115
+ return;
116
+ }
117
+
118
+ const hash = extractFileHashFromUrl(url);
119
+
120
+ if (!hash) {
121
+ return;
122
+ }
123
+
124
+ cacheCardMediaHash(card, hash);
125
+ onReady?.();
126
+ };
127
+
128
+ video.addEventListener('loadedmetadata', capture, { once: true });
129
+ video.addEventListener('loadeddata', capture, { once: true });
130
+
131
+ if (video.src && !video.src.startsWith('blob:')) {
132
+ capture();
133
+ }
134
+
135
+ return null;
136
+ };
137
+
138
+ const cardContainsHash = (card: Element, hash: string) => {
139
+ if (extractFileHashFromCard(card) === hash) {
140
+ return true;
141
+ }
142
+
143
+ return card.innerHTML.includes(hash);
144
+ };
145
+
146
+ export const cardMatchesJob = (
147
+ card: Element,
148
+ job: Pick<VideoOptimizerJob, 'fileId' | 'fileHash'>
149
+ ) => {
150
+ if (!job.fileHash) {
151
+ return true;
152
+ }
153
+
154
+ return cardContainsHash(card, job.fileHash);
155
+ };
156
+
157
+ export const isVideoAssetCard = (card: Element) => {
158
+ if (card.querySelector('video, canvas')) {
159
+ return true;
160
+ }
161
+
162
+ const text = card.textContent ?? '';
163
+
164
+ if (!VIDEO_EXT_PATTERN.test(text)) {
165
+ return false;
166
+ }
167
+
168
+ if (card.querySelector('time')) {
169
+ return true;
170
+ }
171
+
172
+ const hasVideoLabel = Array.from(card.querySelectorAll('span')).some(
173
+ (node) => node.textContent?.trim().toLowerCase() === 'video'
174
+ );
175
+
176
+ return hasVideoLabel;
177
+ };
178
+
179
+ export const collectVideoCards = () => {
180
+ const seen = new Set<Element>();
181
+ const cards: Element[] = [];
182
+ const root = document.querySelector('main') ?? document.body;
183
+
184
+ for (const candidate of root.querySelectorAll(CARD_SELECTOR)) {
185
+ if (!candidate.matches(CARD_SELECTOR) || seen.has(candidate) || !isVideoAssetCard(candidate)) {
186
+ continue;
187
+ }
188
+
189
+ seen.add(candidate);
190
+ cards.push(candidate);
191
+ }
192
+
193
+ return cards;
194
+ };
195
+
196
+ export const prepareCardsForMatching = (cards: Element[], onReady?: () => void) => {
197
+ for (const card of cards) {
198
+ ensureCardMediaHash(card, onReady);
199
+ }
200
+ };
201
+
202
+ const findCardByFileRecord = (
203
+ file: Pick<UploadAssetRecord, 'id' | 'name' | 'hash'>,
204
+ cards: Element[],
205
+ assignedCards: Set<Element>,
206
+ uploadAssets: UploadAssetRecord[]
207
+ ) => {
208
+ if (file.hash) {
209
+ for (const card of cards) {
210
+ if (assignedCards.has(card)) {
211
+ continue;
212
+ }
213
+
214
+ if (cardContainsHash(card, file.hash)) {
215
+ return card;
216
+ }
217
+ }
218
+ }
219
+
220
+ const fileName = file.name;
221
+
222
+ if (!fileName) {
223
+ return null;
224
+ }
225
+
226
+ const sameNameFiles = uploadAssets.filter((asset) => asset.name === fileName);
227
+ const sameNameCards = cards.filter((card) => {
228
+ if (assignedCards.has(card)) {
229
+ return false;
230
+ }
231
+
232
+ return extractFileNameFromCard(card) === fileName;
233
+ });
234
+
235
+ if (sameNameFiles.length === 1 && sameNameCards.length === 1) {
236
+ return sameNameCards[0] ?? null;
237
+ }
238
+
239
+ const fileIndex = sameNameFiles.findIndex((asset) => asset.id === file.id);
240
+
241
+ if (fileIndex >= 0 && fileIndex < sameNameCards.length) {
242
+ return sameNameCards[fileIndex] ?? null;
243
+ }
244
+
245
+ return null;
246
+ };
247
+
248
+ export const findCardForJob = (
249
+ job: Pick<VideoOptimizerJob, 'fileId' | 'fileHash' | 'fileName'>,
250
+ cards: Element[],
251
+ assignedCards: Set<Element>,
252
+ uploadAssets: UploadAssetRecord[] = getUploadAssetsFromCache()
253
+ ) => {
254
+ const stamped = document.querySelector(
255
+ `[data-video-optimizer-file-id="${job.fileId}"]`
256
+ ) as HTMLElement | null;
257
+
258
+ if (stamped?.isConnected && !assignedCards.has(stamped)) {
259
+ jobCardRefs.set(job.fileId, stamped);
260
+ return stamped;
261
+ }
262
+
263
+ const previous = jobCardRefs.get(job.fileId);
264
+
265
+ if (previous?.isConnected && !assignedCards.has(previous)) {
266
+ return previous;
267
+ }
268
+
269
+ jobCardRefs.delete(job.fileId);
270
+
271
+ if (job.fileHash) {
272
+ for (const card of cards) {
273
+ if (assignedCards.has(card)) {
274
+ continue;
275
+ }
276
+
277
+ if (cardContainsHash(card, job.fileHash)) {
278
+ jobCardRefs.set(job.fileId, card);
279
+ cacheCardMediaHash(card, job.fileHash);
280
+ return card;
281
+ }
282
+ }
283
+ }
284
+
285
+ const fileRecord =
286
+ uploadAssets.find((asset) => asset.id === job.fileId) ??
287
+ (job.fileHash || job.fileName
288
+ ? { id: job.fileId, hash: job.fileHash, name: job.fileName }
289
+ : null);
290
+
291
+ if (!fileRecord) {
292
+ return null;
293
+ }
294
+
295
+ const matched = findCardByFileRecord(fileRecord, cards, assignedCards, uploadAssets);
296
+
297
+ if (matched) {
298
+ jobCardRefs.set(job.fileId, matched);
299
+
300
+ if (fileRecord.hash) {
301
+ cacheCardMediaHash(matched, fileRecord.hash);
302
+ }
303
+ }
304
+
305
+ return matched;
306
+ };
307
+
308
+ export const findCardForFile = (
309
+ file: { id: number; hash?: string; url?: string; name?: string },
310
+ assignedCards?: Set<Element>
311
+ ) => {
312
+ const cards = collectVideoCards();
313
+ prepareCardsForMatching(cards);
314
+
315
+ return findCardForJob(
316
+ {
317
+ fileId: file.id,
318
+ fileHash: file.hash ?? (file.url ? extractFileHashFromUrl(file.url) ?? undefined : undefined),
319
+ fileName: file.name,
320
+ },
321
+ cards,
322
+ assignedCards ?? new Set()
323
+ );
324
+ };
325
+
326
+ export const cleanupOrphanProgressHosts = (activeFileIds: Set<number>) => {
327
+ for (const host of document.querySelectorAll('[data-video-optimizer-progress-host]')) {
328
+ const fileId = Number(host.getAttribute('data-video-optimizer-progress-host'));
329
+
330
+ if (!activeFileIds.has(fileId)) {
331
+ host.remove();
332
+ }
333
+ }
334
+
335
+ for (const card of document.querySelectorAll('[data-video-optimizer-file-id]')) {
336
+ const fileId = Number((card as HTMLElement).dataset.videoOptimizerFileId);
337
+
338
+ if (!activeFileIds.has(fileId)) {
339
+ delete (card as HTMLElement).dataset.videoOptimizerFileId;
340
+ jobCardRefs.delete(fileId);
341
+ }
342
+ }
343
+ };
344
+
345
+ export const clearJobCardRefs = (activeFileIds: Set<number>) => {
346
+ for (const fileId of jobCardRefs.keys()) {
347
+ if (!activeFileIds.has(fileId)) {
348
+ jobCardRefs.delete(fileId);
349
+ }
350
+ }
351
+ };
352
+
353
+ export const resolveFileIdForCard = (
354
+ card: Element,
355
+ uploadAssets: UploadAssetRecord[] = getUploadAssetsFromCache()
356
+ ): number | null => {
357
+ const stamped = (card as HTMLElement).dataset.videoOptimizerFileId;
358
+
359
+ if (stamped) {
360
+ const fileId = Number(stamped);
361
+
362
+ if (Number.isFinite(fileId) && fileId > 0) {
363
+ return fileId;
364
+ }
365
+ }
366
+
367
+ const hash = extractFileHashFromCard(card);
368
+
369
+ if (hash) {
370
+ const matches = uploadAssets.filter((asset) => asset.hash === hash);
371
+
372
+ if (matches.length === 1) {
373
+ return matches[0]!.id;
374
+ }
375
+
376
+ if (matches.length > 1) {
377
+ const cards = collectVideoCards().filter((candidate) => cardContainsHash(candidate, hash));
378
+ const cardIndex = cards.indexOf(card);
379
+
380
+ if (cardIndex >= 0 && cardIndex < matches.length) {
381
+ return matches[cardIndex]!.id;
382
+ }
383
+ }
384
+ }
385
+
386
+ const fileName = extractFileNameFromCard(card);
387
+
388
+ if (!fileName) {
389
+ return null;
390
+ }
391
+
392
+ const exactMatches = uploadAssets.filter((asset) => asset.name === fileName);
393
+
394
+ if (exactMatches.length === 1) {
395
+ return exactMatches[0]!.id;
396
+ }
397
+
398
+ const sameNameFiles = exactMatches;
399
+ const sameNameCards = collectVideoCards().filter(
400
+ (candidate) => extractFileNameFromCard(candidate) === fileName
401
+ );
402
+
403
+ if (sameNameFiles.length === 1 && sameNameCards.length === 1) {
404
+ return sameNameFiles[0]!.id;
405
+ }
406
+
407
+ const fileIndex = sameNameCards.indexOf(card);
408
+
409
+ if (fileIndex >= 0 && fileIndex < sameNameFiles.length) {
410
+ return sameNameFiles[fileIndex]!.id;
411
+ }
412
+
413
+ return null;
414
+ };