@recordtimelabel/core 0.4.7 → 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.
@@ -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,7 +31,63 @@ const REQUIRED_FOLDERS = [
31
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
32
32
  ];
33
33
 
34
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.7';
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',
@@ -5289,6 +5345,8 @@ export const buildOperationsFromSnapshotDiff = ({
5289
5345
 
5290
5346
  export default {
5291
5347
  RECORD_TIMELABEL_CORE_VERSION,
5348
+ RECORD_GROUP_IDENTITY_VERSION,
5349
+ RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
5292
5350
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
5293
5351
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
5294
5352
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,