@recordtimelabel/core 0.4.6 → 0.5.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/README.md +17 -5
- package/package.json +3 -2
- package/src/domain/channel-folder.js +377 -0
- package/src/domain/group-identity.js +456 -0
- package/src/domain/hash.js +147 -0
- package/src/domain/legacy.js +359 -0
- package/src/domain/shared.js +132 -0
- package/src/domain/time.js +43 -0
- package/src/domain/twitch-vod.js +303 -0
- package/src/domain.js +45 -0
- package/src/index.js +202 -9
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {fingerprintCanonicalJson} from './hash.js';
|
|
2
|
+
import {buildRecordTimeLabelGroupIndex} from './group-identity.js';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_UNKNOWN_TITLE,
|
|
5
|
+
collectNonVirtualRecords,
|
|
6
|
+
extractTwitchVodIdFromUrl,
|
|
7
|
+
getFirstMatchingUrlValue,
|
|
8
|
+
getRecordUrls,
|
|
9
|
+
normalizeId,
|
|
10
|
+
normalizeRecordPlatform,
|
|
11
|
+
normalizeText,
|
|
12
|
+
parseElapsedSeconds
|
|
13
|
+
} from './shared.js';
|
|
14
|
+
import {parseUtcMillis} from './time.js';
|
|
15
|
+
|
|
16
|
+
const TITLE_BRACKET_PATTERN = /\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/g;
|
|
17
|
+
const NON_WORD_PATTERN = /[^\p{L}\p{N}\s]/gu;
|
|
18
|
+
const MINUTE_MS = 60 * 1000;
|
|
19
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
20
|
+
const RECORD_AFTER_START_GRACE_MS = 10 * MINUTE_MS;
|
|
21
|
+
const STREAM_START_GRACE_MS = 45 * MINUTE_MS;
|
|
22
|
+
const ELAPSED_START_TOLERANCE_MS = 45 * MINUTE_MS;
|
|
23
|
+
const MAX_REASONABLE_STREAM_MS = 48 * HOUR_MS;
|
|
24
|
+
|
|
25
|
+
const getVodStartMs = (vod = {}) => parseUtcMillis(vod.publishedAt || vod.createdAt);
|
|
26
|
+
|
|
27
|
+
const getNextVodStartMs = (value) => (
|
|
28
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
29
|
+
? value
|
|
30
|
+
: parseUtcMillis(value)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const normalizeVodTitleForMatch = (value) => String(value || '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.normalize('NFKC')
|
|
36
|
+
.replace(TITLE_BRACKET_PATTERN, ' ')
|
|
37
|
+
.replace(NON_WORD_PATTERN, ' ')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim();
|
|
40
|
+
|
|
41
|
+
export const calculateVodTitleSimilarity = (expectedTitle, candidateTitle) => {
|
|
42
|
+
const expected = normalizeVodTitleForMatch(expectedTitle);
|
|
43
|
+
const candidate = normalizeVodTitleForMatch(candidateTitle);
|
|
44
|
+
if (!expected || !candidate) return 0;
|
|
45
|
+
if (expected === candidate) return 1;
|
|
46
|
+
if (expected.includes(candidate) || candidate.includes(expected)) return 0.9;
|
|
47
|
+
|
|
48
|
+
const expectedTokens = new Set(expected.split(' ').filter(Boolean));
|
|
49
|
+
const candidateTokens = new Set(candidate.split(' ').filter(Boolean));
|
|
50
|
+
if (expectedTokens.size === 0 || candidateTokens.size === 0) return 0;
|
|
51
|
+
|
|
52
|
+
let intersection = 0;
|
|
53
|
+
expectedTokens.forEach((token) => {
|
|
54
|
+
if (candidateTokens.has(token)) intersection += 1;
|
|
55
|
+
});
|
|
56
|
+
const union = new Set([...expectedTokens, ...candidateTokens]).size;
|
|
57
|
+
return union > 0 ? intersection / union : 0;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const scoreTwitchVodCandidate = (vod = {}, context = {}) => {
|
|
61
|
+
const recordMs = parseUtcMillis(context.recordTimestamp);
|
|
62
|
+
const streamStartMs = parseUtcMillis(context.streamCreatedAt || context.streamStartedAt);
|
|
63
|
+
const elapsedSeconds = parseElapsedSeconds(context.recordLiveTimeSeconds);
|
|
64
|
+
const targetSeconds = parseElapsedSeconds(context.recordTargetTimeSeconds) ?? elapsedSeconds;
|
|
65
|
+
const vodStartMs = getVodStartMs(vod);
|
|
66
|
+
const nextVodStartMs = getNextVodStartMs(context.nextVodStartMs || context.nextVodStartAt);
|
|
67
|
+
|
|
68
|
+
if (!vod?.id || vodStartMs === null) return null;
|
|
69
|
+
|
|
70
|
+
const expectedStartMs = recordMs !== null && elapsedSeconds !== null
|
|
71
|
+
? recordMs - (elapsedSeconds * 1000)
|
|
72
|
+
: null;
|
|
73
|
+
const lengthSeconds = parseElapsedSeconds(vod.lengthSeconds);
|
|
74
|
+
const hasStableAnchor = Boolean(streamStartMs !== null || expectedStartMs !== null);
|
|
75
|
+
const matchedBy = [];
|
|
76
|
+
let score = 0;
|
|
77
|
+
const streamAnchorMs = expectedStartMs !== null ? expectedStartMs : streamStartMs;
|
|
78
|
+
const targetAbsoluteMs = targetSeconds !== null && streamAnchorMs !== null
|
|
79
|
+
? streamAnchorMs + (targetSeconds * 1000)
|
|
80
|
+
: recordMs;
|
|
81
|
+
const lengthEndMs = lengthSeconds !== null ? vodStartMs + (lengthSeconds * 1000) : null;
|
|
82
|
+
const inferredEndMs = nextVodStartMs !== null &&
|
|
83
|
+
nextVodStartMs > vodStartMs &&
|
|
84
|
+
nextVodStartMs - vodStartMs <= MAX_REASONABLE_STREAM_MS
|
|
85
|
+
? nextVodStartMs
|
|
86
|
+
: null;
|
|
87
|
+
const vodEndMs = lengthEndMs || inferredEndMs;
|
|
88
|
+
|
|
89
|
+
if (targetAbsoluteMs !== null) {
|
|
90
|
+
if (targetAbsoluteMs < vodStartMs - RECORD_AFTER_START_GRACE_MS) return null;
|
|
91
|
+
if (vodEndMs !== null && targetAbsoluteMs >= vodEndMs + RECORD_AFTER_START_GRACE_MS) return null;
|
|
92
|
+
if (vodEndMs !== null) {
|
|
93
|
+
score += 80;
|
|
94
|
+
matchedBy.push('target-window');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (expectedStartMs !== null) {
|
|
99
|
+
const startDelta = Math.abs(vodStartMs - expectedStartMs);
|
|
100
|
+
if (startDelta <= ELAPSED_START_TOLERANCE_MS) {
|
|
101
|
+
const closeness = 1 - (startDelta / ELAPSED_START_TOLERANCE_MS);
|
|
102
|
+
score += 85 + (closeness * 15);
|
|
103
|
+
matchedBy.push('elapsed-start');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (streamStartMs !== null && recordMs !== null) {
|
|
108
|
+
const startsInsideStreamWindow =
|
|
109
|
+
vodStartMs >= streamStartMs - STREAM_START_GRACE_MS &&
|
|
110
|
+
vodStartMs <= recordMs + RECORD_AFTER_START_GRACE_MS;
|
|
111
|
+
if (startsInsideStreamWindow) {
|
|
112
|
+
const streamSpan = Math.max(recordMs - streamStartMs, 1);
|
|
113
|
+
const positionPenalty = Math.min(Math.max(vodStartMs - streamStartMs, 0) / streamSpan, 1) * 15;
|
|
114
|
+
score += 70 - positionPenalty;
|
|
115
|
+
matchedBy.push('stream-window');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (recordMs !== null && vodEndMs !== null) {
|
|
120
|
+
const containsRecord =
|
|
121
|
+
recordMs >= vodStartMs - RECORD_AFTER_START_GRACE_MS &&
|
|
122
|
+
recordMs <= vodEndMs + RECORD_AFTER_START_GRACE_MS;
|
|
123
|
+
if (containsRecord) {
|
|
124
|
+
score += 60;
|
|
125
|
+
matchedBy.push('duration-window');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasStableAnchor && recordMs !== null) {
|
|
130
|
+
const recordOffsetMs = recordMs - vodStartMs;
|
|
131
|
+
if (recordOffsetMs >= -RECORD_AFTER_START_GRACE_MS && recordOffsetMs <= MAX_REASONABLE_STREAM_MS) {
|
|
132
|
+
const closeness = 1 - Math.min(Math.max(recordOffsetMs, 0) / MAX_REASONABLE_STREAM_MS, 1);
|
|
133
|
+
score += 30 + (closeness * 20);
|
|
134
|
+
matchedBy.push('legacy-time-window');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (matchedBy.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
const titleSimilarity = calculateVodTitleSimilarity(context.expectedTitle, vod.title || '');
|
|
141
|
+
score += titleSimilarity * 10;
|
|
142
|
+
if (score < (hasStableAnchor ? 55 : 25)) return null;
|
|
143
|
+
|
|
144
|
+
const vodTimeOffsetSeconds = targetSeconds !== null && matchedBy.includes('elapsed-start')
|
|
145
|
+
? targetSeconds
|
|
146
|
+
: targetSeconds !== null && streamAnchorMs !== null
|
|
147
|
+
? Math.max(0, Math.floor(targetSeconds - ((vodStartMs - streamAnchorMs) / 1000)))
|
|
148
|
+
: (recordMs !== null
|
|
149
|
+
? Math.max(0, Math.floor((recordMs - vodStartMs) / 1000))
|
|
150
|
+
: targetSeconds);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
vod,
|
|
154
|
+
score,
|
|
155
|
+
matchedBy,
|
|
156
|
+
titleSimilarity,
|
|
157
|
+
vodStartAt: new Date(vodStartMs).toISOString(),
|
|
158
|
+
vodTimeOffsetSeconds
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const selectBestMatchingTwitchVod = (items = [], context = {}) => {
|
|
163
|
+
const normalizedItems = (Array.isArray(items) ? items : [])
|
|
164
|
+
.map((item) => item?.node || item)
|
|
165
|
+
.map((vod) => ({vod, startMs: getVodStartMs(vod)}))
|
|
166
|
+
.filter((entry) => entry.vod?.id && entry.startMs !== null)
|
|
167
|
+
.sort((a, b) => a.startMs - b.startMs);
|
|
168
|
+
|
|
169
|
+
return normalizedItems
|
|
170
|
+
.map((entry, index) => scoreTwitchVodCandidate(entry.vod, {
|
|
171
|
+
...context,
|
|
172
|
+
nextVodStartMs: normalizedItems[index + 1]?.startMs ?? null
|
|
173
|
+
}))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.sort((a, b) => b.score - a.score)[0] || null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const valuesEqual = (left, right) => {
|
|
179
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
180
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
181
|
+
return left.every((value, index) => valuesEqual(value, right[index]));
|
|
182
|
+
}
|
|
183
|
+
return left === right;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const recordHasConflictingIdentity = (record, source) => {
|
|
187
|
+
const recordStreamId = normalizeText(record?.streamId);
|
|
188
|
+
const sourceStreamId = normalizeText(source?.streamId);
|
|
189
|
+
if (recordStreamId && sourceStreamId && recordStreamId !== sourceStreamId) return true;
|
|
190
|
+
const recordVodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
191
|
+
const sourceVodId = normalizeText(source?.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
|
|
192
|
+
if (recordVodId && sourceVodId && recordVodId !== sourceVodId) return true;
|
|
193
|
+
return false;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const buildRecordTimeLabelVodPatchOperationId = (recordId, patch = {}) => (
|
|
197
|
+
`record.update:${normalizeId(recordId)}:vod-patch:${fingerprintCanonicalJson(patch || {})}`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export const buildKnownTwitchVodRecordPatches = ({
|
|
201
|
+
records = [],
|
|
202
|
+
groupIndex = null,
|
|
203
|
+
fallbackPlatform,
|
|
204
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
205
|
+
} = {}) => {
|
|
206
|
+
const safeRecords = collectNonVirtualRecords(records);
|
|
207
|
+
const index = groupIndex || buildRecordTimeLabelGroupIndex({
|
|
208
|
+
records: safeRecords,
|
|
209
|
+
fallbackPlatform,
|
|
210
|
+
unknownTitle
|
|
211
|
+
});
|
|
212
|
+
const conflictGroupIds = new Set(index.diagnostics?.conflictGroupIds || []);
|
|
213
|
+
const sourceByGroupId = {};
|
|
214
|
+
|
|
215
|
+
safeRecords.forEach((record) => {
|
|
216
|
+
if (normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record?.hasVod !== true) return;
|
|
217
|
+
const recordId = normalizeId(record?.id);
|
|
218
|
+
const groupId = index.groupIdByRecordId[recordId];
|
|
219
|
+
if (!groupId || conflictGroupIds.has(groupId)) return;
|
|
220
|
+
const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
221
|
+
if (!vodId) return;
|
|
222
|
+
const current = sourceByGroupId[groupId];
|
|
223
|
+
const currentScore = current
|
|
224
|
+
? Number(Boolean(current.vodStartAt || current.vodPublishedAt)) +
|
|
225
|
+
Number(Number.isFinite(Number(current.vodTimeOffsetSeconds)))
|
|
226
|
+
: -1;
|
|
227
|
+
const nextScore = Number(Boolean(record.vodStartAt || record.vodPublishedAt)) +
|
|
228
|
+
Number(Number.isFinite(Number(record.vodTimeOffsetSeconds)));
|
|
229
|
+
if (!current || nextScore > currentScore) sourceByGroupId[groupId] = record;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const patches = [];
|
|
233
|
+
safeRecords.forEach((record) => {
|
|
234
|
+
const recordId = normalizeId(record?.id);
|
|
235
|
+
if (!recordId || normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record.hasVod === true) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const groupId = index.groupIdByRecordId[recordId];
|
|
239
|
+
if (!groupId || conflictGroupIds.has(groupId)) return;
|
|
240
|
+
const source = sourceByGroupId[groupId];
|
|
241
|
+
if (!source || recordHasConflictingIdentity(record, source)) return;
|
|
242
|
+
const vodId = normalizeText(source.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
|
|
243
|
+
if (!vodId) return;
|
|
244
|
+
|
|
245
|
+
const vodStartAt = source.vodStartAt || source.vodPublishedAt || null;
|
|
246
|
+
const vodStartMs = parseUtcMillis(vodStartAt);
|
|
247
|
+
const recordCreatedAtMs = parseUtcMillis(record.createdAt);
|
|
248
|
+
const configuredOffsetSeconds = Number.isFinite(Number(record.timeOffsetSeconds))
|
|
249
|
+
? Math.max(0, Number(record.timeOffsetSeconds))
|
|
250
|
+
: 0;
|
|
251
|
+
const timestampOffsetSeconds = vodStartMs !== null && recordCreatedAtMs !== null &&
|
|
252
|
+
recordCreatedAtMs >= vodStartMs
|
|
253
|
+
? Math.max(0, Math.floor((recordCreatedAtMs - vodStartMs) / 1000) - configuredOffsetSeconds)
|
|
254
|
+
: null;
|
|
255
|
+
const fallbackOffsetSeconds = parseElapsedSeconds(record.liveTime || record.originalLiveTime);
|
|
256
|
+
const vodTimeOffsetSeconds = timestampOffsetSeconds ?? fallbackOffsetSeconds;
|
|
257
|
+
const sourceVideoUrl = getRecordUrls(source).find((url) => extractTwitchVodIdFromUrl(url) === vodId) ||
|
|
258
|
+
`https://www.twitch.tv/videos/${vodId}`;
|
|
259
|
+
const vodMatchedBy = Array.from(new Set([
|
|
260
|
+
...(Array.isArray(source.vodMatchedBy) ? source.vodMatchedBy : []),
|
|
261
|
+
'sibling-record'
|
|
262
|
+
]));
|
|
263
|
+
const candidate = {
|
|
264
|
+
hasVod: true,
|
|
265
|
+
videoUrl: sourceVideoUrl,
|
|
266
|
+
vodId,
|
|
267
|
+
vodPublishedAt: source.vodPublishedAt || vodStartAt,
|
|
268
|
+
vodStartAt,
|
|
269
|
+
vodTitle: source.vodTitle || '',
|
|
270
|
+
vodLookupVersion: 2,
|
|
271
|
+
vodMatchedBy
|
|
272
|
+
};
|
|
273
|
+
if (Number.isFinite(vodTimeOffsetSeconds)) candidate.vodTimeOffsetSeconds = vodTimeOffsetSeconds;
|
|
274
|
+
if (Number.isFinite(Number(source.vodMatchConfidence))) {
|
|
275
|
+
candidate.vodMatchConfidence = Number(source.vodMatchConfidence);
|
|
276
|
+
}
|
|
277
|
+
if (source.downloadUrl) {
|
|
278
|
+
candidate.downloadUrl = source.downloadUrl;
|
|
279
|
+
candidate.isDirectM3u8 = source.isDirectM3u8 || false;
|
|
280
|
+
candidate.m3u8Type = source.m3u8Type || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const patch = {};
|
|
284
|
+
Object.entries(candidate).forEach(([field, value]) => {
|
|
285
|
+
if (!valuesEqual(record[field], value)) patch[field] = value;
|
|
286
|
+
});
|
|
287
|
+
if (Object.keys(patch).length === 0) return;
|
|
288
|
+
patches.push({
|
|
289
|
+
recordId,
|
|
290
|
+
patch,
|
|
291
|
+
operationId: buildRecordTimeLabelVodPatchOperationId(recordId, patch)
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
patches,
|
|
297
|
+
diagnostics: {
|
|
298
|
+
sourceGroupCount: Object.keys(sourceByGroupId).length,
|
|
299
|
+
patchCount: patches.length,
|
|
300
|
+
skippedConflictGroupCount: conflictGroupIds.size
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
};
|
package/src/domain.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const RECORD_TIMELABEL_DOMAIN_CAPABILITIES = Object.freeze([
|
|
2
|
+
'group-identity-v1',
|
|
3
|
+
'twitch-vod-matching-v1',
|
|
4
|
+
'legacy-import-v1',
|
|
5
|
+
'channel-folder-planning-v1'
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
10
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
11
|
+
buildRecordTimeLabelGroupIndex,
|
|
12
|
+
buildRecordTimeLabelGroupMetadata,
|
|
13
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
14
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
15
|
+
canonicalizeRecordTimeLabelGroupView
|
|
16
|
+
} from './domain/group-identity.js';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
extractTwitchVodIdFromUrl,
|
|
20
|
+
extractYouTubeVideoIdFromUrl
|
|
21
|
+
} from './domain/shared.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
buildKnownTwitchVodRecordPatches,
|
|
25
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
26
|
+
calculateVodTitleSimilarity,
|
|
27
|
+
normalizeVodTitleForMatch,
|
|
28
|
+
scoreTwitchVodCandidate,
|
|
29
|
+
selectBestMatchingTwitchVod
|
|
30
|
+
} from './domain/twitch-vod.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
34
|
+
remapLegacyRecordTimeLabelFolderAliases
|
|
35
|
+
} from './domain/legacy.js';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
39
|
+
collectRecordTimeLabelChannelCandidates,
|
|
40
|
+
extractChannelHandleFromUrl,
|
|
41
|
+
findBestRecordTimeLabelChannelFolder,
|
|
42
|
+
getChannelMatchKey,
|
|
43
|
+
isPendingChannelFolderId,
|
|
44
|
+
isValidChannelName
|
|
45
|
+
} from './domain/channel-folder.js';
|
package/src/index.js
CHANGED
|
@@ -31,12 +31,69 @@ const REQUIRED_FOLDERS = [
|
|
|
31
31
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
import {
|
|
35
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
36
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
37
|
+
buildKnownTwitchVodRecordPatches,
|
|
38
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
39
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
40
|
+
buildRecordTimeLabelGroupIndex,
|
|
41
|
+
buildRecordTimeLabelGroupMetadata,
|
|
42
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
43
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
44
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
45
|
+
calculateVodTitleSimilarity,
|
|
46
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
47
|
+
collectRecordTimeLabelChannelCandidates,
|
|
48
|
+
extractChannelHandleFromUrl,
|
|
49
|
+
extractTwitchVodIdFromUrl,
|
|
50
|
+
extractYouTubeVideoIdFromUrl,
|
|
51
|
+
findBestRecordTimeLabelChannelFolder,
|
|
52
|
+
getChannelMatchKey,
|
|
53
|
+
isPendingChannelFolderId,
|
|
54
|
+
isValidChannelName,
|
|
55
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
56
|
+
normalizeVodTitleForMatch,
|
|
57
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
58
|
+
scoreTwitchVodCandidate,
|
|
59
|
+
selectBestMatchingTwitchVod
|
|
60
|
+
} from './domain.js';
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
64
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
65
|
+
buildKnownTwitchVodRecordPatches,
|
|
66
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
67
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
68
|
+
buildRecordTimeLabelGroupIndex,
|
|
69
|
+
buildRecordTimeLabelGroupMetadata,
|
|
70
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
71
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
72
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
73
|
+
calculateVodTitleSimilarity,
|
|
74
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
75
|
+
collectRecordTimeLabelChannelCandidates,
|
|
76
|
+
extractChannelHandleFromUrl,
|
|
77
|
+
extractTwitchVodIdFromUrl,
|
|
78
|
+
extractYouTubeVideoIdFromUrl,
|
|
79
|
+
findBestRecordTimeLabelChannelFolder,
|
|
80
|
+
getChannelMatchKey,
|
|
81
|
+
isPendingChannelFolderId,
|
|
82
|
+
isValidChannelName,
|
|
83
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
84
|
+
normalizeVodTitleForMatch,
|
|
85
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
86
|
+
scoreTwitchVodCandidate,
|
|
87
|
+
selectBestMatchingTwitchVod
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.5.0';
|
|
35
91
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
36
92
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
37
93
|
'fifo-retry-fence',
|
|
38
94
|
'baseline-refresh-after-rejection',
|
|
39
95
|
'sync-batch-boundary',
|
|
96
|
+
'remote-subscription-readiness',
|
|
40
97
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
41
98
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
42
99
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
@@ -3157,6 +3214,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3157
3214
|
let unsubscribeCloud = null;
|
|
3158
3215
|
let unsubscribeSession = null;
|
|
3159
3216
|
let subscriptionContext = null;
|
|
3217
|
+
let remoteReadyGeneration = 0;
|
|
3218
|
+
let remoteReadyState = {
|
|
3219
|
+
generation: 0,
|
|
3220
|
+
settled: true,
|
|
3221
|
+
promise: Promise.resolve({
|
|
3222
|
+
success: true,
|
|
3223
|
+
skipped: true,
|
|
3224
|
+
reason: 'not_initialized',
|
|
3225
|
+
generation: 0
|
|
3226
|
+
})
|
|
3227
|
+
};
|
|
3160
3228
|
let bootstrapAttemptSequence = 0;
|
|
3161
3229
|
const listeners = new Set();
|
|
3162
3230
|
let queue = Promise.resolve();
|
|
@@ -3501,7 +3569,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3501
3569
|
return {success: true, ignored: true};
|
|
3502
3570
|
}
|
|
3503
3571
|
let baselineValue = remoteValue;
|
|
3504
|
-
|
|
3572
|
+
const remoteState = remote?.state ?? remote?.data;
|
|
3573
|
+
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
3574
|
+
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
3505
3575
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
3506
3576
|
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3507
3577
|
}
|
|
@@ -3517,7 +3587,49 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3517
3587
|
return getSnapshot();
|
|
3518
3588
|
};
|
|
3519
3589
|
|
|
3520
|
-
const
|
|
3590
|
+
const createRemoteReadyState = () => {
|
|
3591
|
+
const generation = ++remoteReadyGeneration;
|
|
3592
|
+
let resolvePromise;
|
|
3593
|
+
let rejectPromise;
|
|
3594
|
+
const state = {
|
|
3595
|
+
generation,
|
|
3596
|
+
settled: false,
|
|
3597
|
+
promise: new Promise((resolve, reject) => {
|
|
3598
|
+
resolvePromise = resolve;
|
|
3599
|
+
rejectPromise = reject;
|
|
3600
|
+
}),
|
|
3601
|
+
resolve(value = {}) {
|
|
3602
|
+
if (state.settled) return;
|
|
3603
|
+
state.settled = true;
|
|
3604
|
+
resolvePromise({
|
|
3605
|
+
success: true,
|
|
3606
|
+
...(value && typeof value === 'object' ? value : {}),
|
|
3607
|
+
generation
|
|
3608
|
+
});
|
|
3609
|
+
},
|
|
3610
|
+
reject(error) {
|
|
3611
|
+
if (state.settled) return;
|
|
3612
|
+
state.settled = true;
|
|
3613
|
+
rejectPromise(error);
|
|
3614
|
+
}
|
|
3615
|
+
};
|
|
3616
|
+
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
3617
|
+
// waiter without turning clients that have not adopted it yet into an
|
|
3618
|
+
// unhandled-rejection source.
|
|
3619
|
+
state.promise.catch(() => null);
|
|
3620
|
+
return state;
|
|
3621
|
+
};
|
|
3622
|
+
|
|
3623
|
+
const staleRemoteReady = (state, reason = 'stale_session') => {
|
|
3624
|
+
if (!state || state.settled) return;
|
|
3625
|
+
state.resolve({success: false, stale: true, reason});
|
|
3626
|
+
};
|
|
3627
|
+
|
|
3628
|
+
const isCurrentRemoteReady = (state) => (
|
|
3629
|
+
!destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
|
|
3630
|
+
);
|
|
3631
|
+
|
|
3632
|
+
const stopCloudSubscription = ({settleReady = true} = {}) => {
|
|
3521
3633
|
if (typeof unsubscribeCloud === 'function') {
|
|
3522
3634
|
try { unsubscribeCloud(); } catch (error) {
|
|
3523
3635
|
logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
|
|
@@ -3525,20 +3637,87 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3525
3637
|
}
|
|
3526
3638
|
unsubscribeCloud = null;
|
|
3527
3639
|
subscriptionContext = null;
|
|
3640
|
+
if (settleReady) staleRemoteReady(remoteReadyState);
|
|
3528
3641
|
};
|
|
3529
3642
|
|
|
3530
3643
|
const startCloudSubscription = (captured, candidate) => {
|
|
3531
3644
|
stopCloudSubscription();
|
|
3532
|
-
|
|
3645
|
+
const readyState = createRemoteReadyState();
|
|
3646
|
+
remoteReadyState = readyState;
|
|
3647
|
+
if (!initialized || hydrationRequired) {
|
|
3648
|
+
readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
if (!captured?.uid) {
|
|
3652
|
+
readyState.resolve({skipped: true, reason: 'anonymous'});
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
if (typeof cloud?.subscribe !== 'function') {
|
|
3656
|
+
readyState.resolve({skipped: true, reason: 'no_subscription'});
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3533
3659
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3534
3660
|
subscriptionContext = context;
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3661
|
+
let subscriptionReturned = false;
|
|
3662
|
+
let adapterReadyExpected = false;
|
|
3663
|
+
let firstCallbackResult = null;
|
|
3664
|
+
const onRemote = (remoteValue) => {
|
|
3665
|
+
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
3666
|
+
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
3667
|
+
}
|
|
3668
|
+
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
3669
|
+
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
3670
|
+
callbackResult.then((result) => {
|
|
3671
|
+
if (
|
|
3672
|
+
subscriptionReturned &&
|
|
3673
|
+
!adapterReadyExpected &&
|
|
3674
|
+
isCurrentRemoteReady(readyState)
|
|
3675
|
+
) {
|
|
3676
|
+
readyState.resolve({
|
|
3677
|
+
revision: workspace.remoteBaseline.revision,
|
|
3678
|
+
result
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
}).catch((error) => {
|
|
3682
|
+
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3683
|
+
readyState.reject(error);
|
|
3684
|
+
}
|
|
3538
3685
|
logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
|
|
3539
|
-
return {success: false, error};
|
|
3540
3686
|
});
|
|
3541
|
-
|
|
3687
|
+
return callbackResult;
|
|
3688
|
+
};
|
|
3689
|
+
let disposer;
|
|
3690
|
+
try {
|
|
3691
|
+
disposer = cloud.subscribe(onRemote, context);
|
|
3692
|
+
} catch (error) {
|
|
3693
|
+
readyState.reject(error);
|
|
3694
|
+
logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
|
|
3698
|
+
adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
|
|
3699
|
+
subscriptionReturned = true;
|
|
3700
|
+
if (adapterReadyExpected) {
|
|
3701
|
+
Promise.resolve(disposer.ready).then((result) => {
|
|
3702
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
3703
|
+
readyState.resolve({
|
|
3704
|
+
revision: workspace.remoteBaseline.revision,
|
|
3705
|
+
result
|
|
3706
|
+
});
|
|
3707
|
+
}).catch((error) => {
|
|
3708
|
+
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3709
|
+
readyState.reject(error);
|
|
3710
|
+
}
|
|
3711
|
+
});
|
|
3712
|
+
} else if (firstCallbackResult) {
|
|
3713
|
+
firstCallbackResult.then((result) => {
|
|
3714
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
3715
|
+
readyState.resolve({
|
|
3716
|
+
revision: workspace.remoteBaseline.revision,
|
|
3717
|
+
result
|
|
3718
|
+
});
|
|
3719
|
+
}).catch(() => null);
|
|
3720
|
+
}
|
|
3542
3721
|
};
|
|
3543
3722
|
|
|
3544
3723
|
const initialize = async () => {
|
|
@@ -4112,6 +4291,18 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4112
4291
|
return queue.catch(() => null);
|
|
4113
4292
|
},
|
|
4114
4293
|
|
|
4294
|
+
waitForRemoteReady() {
|
|
4295
|
+
if (destroyed) {
|
|
4296
|
+
return Promise.resolve({
|
|
4297
|
+
success: false,
|
|
4298
|
+
stale: true,
|
|
4299
|
+
reason: 'stale_session',
|
|
4300
|
+
generation: remoteReadyState.generation
|
|
4301
|
+
});
|
|
4302
|
+
}
|
|
4303
|
+
return remoteReadyState.promise;
|
|
4304
|
+
},
|
|
4305
|
+
|
|
4115
4306
|
getSnapshot,
|
|
4116
4307
|
|
|
4117
4308
|
subscribe(listener) {
|
|
@@ -5154,6 +5345,8 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
5154
5345
|
|
|
5155
5346
|
export default {
|
|
5156
5347
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
5348
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
5349
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
5157
5350
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5158
5351
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5159
5352
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|