@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.
- package/README.md +7 -3
- 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 +59 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import {fingerprintCanonicalJson} from './hash.js';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_UNKNOWN_CHANNEL,
|
|
4
|
+
DEFAULT_UNKNOWN_TITLE,
|
|
5
|
+
GROUP_ID_PREFIX,
|
|
6
|
+
TWITCH_STREAM_ANCHOR_TOLERANCE_MS,
|
|
7
|
+
collectNonVirtualRecords,
|
|
8
|
+
extractTwitchVodIdFromUrl,
|
|
9
|
+
extractYouTubeVideoIdFromUrl,
|
|
10
|
+
getFirstMatchingUrlValue,
|
|
11
|
+
getRecordTitle,
|
|
12
|
+
normalizeFallbackPlatform,
|
|
13
|
+
normalizeId,
|
|
14
|
+
normalizeIdList,
|
|
15
|
+
normalizeRecordPlatform,
|
|
16
|
+
normalizeText,
|
|
17
|
+
parseElapsedSeconds
|
|
18
|
+
} from './shared.js';
|
|
19
|
+
import {parseUtcMillis, roundDownToUtcMinuteIso, toCreatedAtMillis} from './time.js';
|
|
20
|
+
|
|
21
|
+
export const RECORD_GROUP_IDENTITY_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
export const buildRecordTimeLabelStandaloneGroupIdentity = (
|
|
24
|
+
record,
|
|
25
|
+
fallbackPlatform,
|
|
26
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
27
|
+
) => {
|
|
28
|
+
const platform = normalizeRecordPlatform(record, fallbackPlatform);
|
|
29
|
+
|
|
30
|
+
if (platform === 'youtube') {
|
|
31
|
+
const videoId = normalizeText(record?.videoId) || getFirstMatchingUrlValue(record, extractYouTubeVideoIdFromUrl);
|
|
32
|
+
if (videoId) return `youtube:video:${videoId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (platform === 'twitch') {
|
|
36
|
+
const streamId = normalizeText(record?.streamId);
|
|
37
|
+
const streamCreatedAt = normalizeText(record?.streamCreatedAt || record?.streamStartedAt);
|
|
38
|
+
const channelHandle = normalizeText(
|
|
39
|
+
record?.channelHandle || record?.channelUsername || record?.username
|
|
40
|
+
).toLowerCase();
|
|
41
|
+
const hasLiveIdentity = record?.isLiveStream === true || !!streamId || !!streamCreatedAt;
|
|
42
|
+
|
|
43
|
+
if (hasLiveIdentity) {
|
|
44
|
+
if (streamId) return `twitch:stream:${streamId}`;
|
|
45
|
+
if (channelHandle && streamCreatedAt) {
|
|
46
|
+
return `twitch:stream-start:${channelHandle}:${streamCreatedAt}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
51
|
+
if (vodId) return `twitch:vod:${vodId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `legacy:${platform}:${getRecordTitle(record, unknownTitle)}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const buildRecordTimeLabelStandaloneGroupId = (
|
|
58
|
+
record,
|
|
59
|
+
fallbackPlatform,
|
|
60
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
61
|
+
) => `${GROUP_ID_PREFIX}${buildRecordTimeLabelStandaloneGroupIdentity(record, fallbackPlatform, unknownTitle)}`;
|
|
62
|
+
|
|
63
|
+
export const buildLegacyRecordTimeLabelGroupId = (
|
|
64
|
+
record,
|
|
65
|
+
fallbackPlatform,
|
|
66
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
67
|
+
) => `${GROUP_ID_PREFIX}${record?.title || unknownTitle}__${record?.platform || normalizeFallbackPlatform(fallbackPlatform)}`;
|
|
68
|
+
|
|
69
|
+
const buildFallbackRecordGroupId = (
|
|
70
|
+
record,
|
|
71
|
+
fallbackPlatform,
|
|
72
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
73
|
+
) => `${GROUP_ID_PREFIX}legacy:${normalizeRecordPlatform(record, fallbackPlatform)}:${getRecordTitle(record, unknownTitle)}`;
|
|
74
|
+
|
|
75
|
+
const getTwitchLiveIdentityParts = (record = {}, fallbackPlatform) => {
|
|
76
|
+
if (normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch') {
|
|
77
|
+
return {
|
|
78
|
+
streamId: '',
|
|
79
|
+
streamStartKey: '',
|
|
80
|
+
vodId: '',
|
|
81
|
+
channelKey: '',
|
|
82
|
+
streamAnchorMs: null,
|
|
83
|
+
hasExactStreamStart: false
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const streamId = normalizeText(record?.streamId);
|
|
88
|
+
const streamCreatedAt = normalizeText(record?.streamCreatedAt || record?.streamStartedAt);
|
|
89
|
+
const channelHandle = normalizeText(
|
|
90
|
+
record?.channelHandle || record?.channelUsername || record?.username
|
|
91
|
+
).toLowerCase();
|
|
92
|
+
const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
93
|
+
const explicitAnchorValue = streamCreatedAt || normalizeText(record?.vodStartAt || record?.vodPublishedAt);
|
|
94
|
+
const explicitAnchorMs = parseUtcMillis(explicitAnchorValue);
|
|
95
|
+
const createdAtMs = parseUtcMillis(record?.createdAt);
|
|
96
|
+
const elapsedSeconds = parseElapsedSeconds(record?.originalLiveTime || record?.liveTime);
|
|
97
|
+
const currentTimeSeconds = parseElapsedSeconds(record?.currentTime);
|
|
98
|
+
const inferredElapsed = elapsedSeconds === null ? currentTimeSeconds : elapsedSeconds;
|
|
99
|
+
const inferredAnchorMs = createdAtMs !== null && inferredElapsed !== null
|
|
100
|
+
? createdAtMs - (inferredElapsed * 1000)
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
streamId,
|
|
105
|
+
streamStartKey: channelHandle && streamCreatedAt
|
|
106
|
+
? `twitch:stream-start:${channelHandle}:${streamCreatedAt}`
|
|
107
|
+
: '',
|
|
108
|
+
vodId,
|
|
109
|
+
channelKey: channelHandle || normalizeText(record?.streamerName || record?.channelName).toLowerCase(),
|
|
110
|
+
streamAnchorMs: explicitAnchorMs !== null
|
|
111
|
+
? explicitAnchorMs
|
|
112
|
+
: inferredAnchorMs,
|
|
113
|
+
hasExactStreamStart: Boolean(streamCreatedAt && parseUtcMillis(streamCreatedAt) !== null)
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const addAliasTarget = (aliasTargetsByGroupId, alias, canonicalId) => {
|
|
118
|
+
const normalizedAlias = normalizeId(alias);
|
|
119
|
+
const normalizedCanonicalId = normalizeId(canonicalId);
|
|
120
|
+
if (!normalizedAlias || !normalizedCanonicalId) return;
|
|
121
|
+
const existing = aliasTargetsByGroupId[normalizedAlias] || [];
|
|
122
|
+
if (existing.includes(normalizedCanonicalId)) return;
|
|
123
|
+
aliasTargetsByGroupId[normalizedAlias] = [...existing, normalizedCanonicalId];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const sortGroupIdsByLatestCreatedAt = (groupIds = [], groupsById = {}) => (
|
|
127
|
+
[...groupIds].sort((left, right) => {
|
|
128
|
+
const delta = (groupsById[right]?.latestCreatedAt || 0) - (groupsById[left]?.latestCreatedAt || 0);
|
|
129
|
+
if (delta !== 0) return delta;
|
|
130
|
+
return String(left).localeCompare(String(right));
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
export const buildRecordTimeLabelGroupIndex = ({
|
|
135
|
+
records = [],
|
|
136
|
+
fallbackPlatform,
|
|
137
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
138
|
+
} = {}) => {
|
|
139
|
+
const platform = normalizeFallbackPlatform(fallbackPlatform);
|
|
140
|
+
const safeRecords = collectNonVirtualRecords(records);
|
|
141
|
+
const seenIds = new Set();
|
|
142
|
+
const indexedRecords = [];
|
|
143
|
+
let skippedMissingIdCount = 0;
|
|
144
|
+
let skippedDuplicateIdCount = 0;
|
|
145
|
+
|
|
146
|
+
safeRecords.forEach((record) => {
|
|
147
|
+
const recordId = normalizeId(record?.id || record?.recordId);
|
|
148
|
+
if (!recordId) {
|
|
149
|
+
skippedMissingIdCount += 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (seenIds.has(recordId)) {
|
|
153
|
+
skippedDuplicateIdCount += 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
seenIds.add(recordId);
|
|
157
|
+
indexedRecords.push({...record, id: recordId});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const identityParts = indexedRecords.map((record) => getTwitchLiveIdentityParts(record, platform));
|
|
161
|
+
const parents = indexedRecords.map((_, index) => index);
|
|
162
|
+
const clusterStreamIds = identityParts.map((parts) => new Set(parts.streamId ? [parts.streamId] : []));
|
|
163
|
+
const clusterVodIds = identityParts.map((parts) => new Set(parts.vodId ? [parts.vodId] : []));
|
|
164
|
+
let conflictRefusalCount = 0;
|
|
165
|
+
|
|
166
|
+
const find = (index) => {
|
|
167
|
+
let root = index;
|
|
168
|
+
while (parents[root] !== root) root = parents[root];
|
|
169
|
+
while (parents[index] !== index) {
|
|
170
|
+
const next = parents[index];
|
|
171
|
+
parents[index] = root;
|
|
172
|
+
index = next;
|
|
173
|
+
}
|
|
174
|
+
return root;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const union = (left, right) => {
|
|
178
|
+
const leftRoot = find(left);
|
|
179
|
+
const rightRoot = find(right);
|
|
180
|
+
if (leftRoot === rightRoot) return true;
|
|
181
|
+
const mergedStreamIds = new Set([...clusterStreamIds[leftRoot], ...clusterStreamIds[rightRoot]]);
|
|
182
|
+
const mergedVodIds = new Set([...clusterVodIds[leftRoot], ...clusterVodIds[rightRoot]]);
|
|
183
|
+
if (mergedStreamIds.size > 1 || (mergedVodIds.size > 1 && mergedStreamIds.size === 0)) {
|
|
184
|
+
conflictRefusalCount += 1;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
parents[rightRoot] = leftRoot;
|
|
188
|
+
clusterStreamIds[leftRoot] = mergedStreamIds;
|
|
189
|
+
clusterVodIds[leftRoot] = mergedVodIds;
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const canShareTwitchStream = (left, right) => {
|
|
194
|
+
if (left.streamId && right.streamId && left.streamId !== right.streamId) return false;
|
|
195
|
+
if (left.vodId && right.vodId && left.vodId !== right.vodId) {
|
|
196
|
+
return Boolean(left.streamId && left.streamId === right.streamId);
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const unionByExactIdentity = (getKey, canUnion = () => true) => {
|
|
202
|
+
const firstIndexByKey = new Map();
|
|
203
|
+
identityParts.forEach((parts, index) => {
|
|
204
|
+
const key = getKey(parts);
|
|
205
|
+
if (!key) return;
|
|
206
|
+
if (firstIndexByKey.has(key)) {
|
|
207
|
+
const firstIndex = firstIndexByKey.get(key);
|
|
208
|
+
if (canUnion(parts, identityParts[firstIndex])) union(index, firstIndex);
|
|
209
|
+
} else firstIndexByKey.set(key, index);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
unionByExactIdentity((parts) => parts.streamId && `stream:${parts.streamId}`);
|
|
214
|
+
unionByExactIdentity(
|
|
215
|
+
(parts) => parts.streamStartKey && `start:${parts.streamStartKey}`,
|
|
216
|
+
canShareTwitchStream
|
|
217
|
+
);
|
|
218
|
+
unionByExactIdentity((parts) => parts.vodId && `vod:${parts.vodId}`, canShareTwitchStream);
|
|
219
|
+
|
|
220
|
+
const anchoredIndexesByChannel = new Map();
|
|
221
|
+
identityParts.forEach((parts, index) => {
|
|
222
|
+
if (!parts.channelKey || !Number.isFinite(parts.streamAnchorMs)) return;
|
|
223
|
+
const indexes = anchoredIndexesByChannel.get(parts.channelKey) || [];
|
|
224
|
+
indexes.push(index);
|
|
225
|
+
anchoredIndexesByChannel.set(parts.channelKey, indexes);
|
|
226
|
+
});
|
|
227
|
+
anchoredIndexesByChannel.forEach((indexes) => {
|
|
228
|
+
indexes.sort((left, right) => (
|
|
229
|
+
identityParts[left].streamAnchorMs - identityParts[right].streamAnchorMs
|
|
230
|
+
));
|
|
231
|
+
for (let index = 1; index < indexes.length; index += 1) {
|
|
232
|
+
const previous = indexes[index - 1];
|
|
233
|
+
const current = indexes[index];
|
|
234
|
+
if (
|
|
235
|
+
canShareTwitchStream(identityParts[current], identityParts[previous]) &&
|
|
236
|
+
identityParts[current].streamAnchorMs - identityParts[previous].streamAnchorMs <=
|
|
237
|
+
TWITCH_STREAM_ANCHOR_TOLERANCE_MS
|
|
238
|
+
) {
|
|
239
|
+
union(previous, current);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const clusterIndexes = new Map();
|
|
245
|
+
indexedRecords.forEach((_, index) => {
|
|
246
|
+
const root = find(index);
|
|
247
|
+
const indexes = clusterIndexes.get(root) || [];
|
|
248
|
+
indexes.push(index);
|
|
249
|
+
clusterIndexes.set(root, indexes);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const canonicalGroupIdByRoot = new Map();
|
|
253
|
+
const conflictGroupIds = [];
|
|
254
|
+
clusterIndexes.forEach((indexes, root) => {
|
|
255
|
+
const parts = indexes.map((index) => identityParts[index]);
|
|
256
|
+
const streamIds = [...new Set(parts.map((entry) => entry.streamId).filter(Boolean))];
|
|
257
|
+
const vodIds = [...new Set(parts.map((entry) => entry.vodId).filter(Boolean))];
|
|
258
|
+
if (streamIds.length > 1 || (vodIds.length > 1 && streamIds.length === 0)) {
|
|
259
|
+
conflictGroupIds.push(root);
|
|
260
|
+
}
|
|
261
|
+
if (indexes.length < 2) return;
|
|
262
|
+
const streamId = streamIds.slice().sort()[0];
|
|
263
|
+
const vodId = vodIds.slice().sort()[0];
|
|
264
|
+
const streamStartKey = parts.map((entry) => entry.streamStartKey).filter(Boolean).sort()[0];
|
|
265
|
+
if (streamId) {
|
|
266
|
+
canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}twitch:stream:${streamId}`);
|
|
267
|
+
} else if (vodId) {
|
|
268
|
+
canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}twitch:vod:${vodId}`);
|
|
269
|
+
} else if (streamStartKey) {
|
|
270
|
+
canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}${streamStartKey}`);
|
|
271
|
+
} else {
|
|
272
|
+
const anchoredPart = parts
|
|
273
|
+
.filter((entry) => entry.channelKey && Number.isFinite(entry.streamAnchorMs))
|
|
274
|
+
.sort((left, right) => left.streamAnchorMs - right.streamAnchorMs)[0];
|
|
275
|
+
if (anchoredPart) {
|
|
276
|
+
const roundedAnchor = roundDownToUtcMinuteIso(anchoredPart.streamAnchorMs);
|
|
277
|
+
canonicalGroupIdByRoot.set(
|
|
278
|
+
root,
|
|
279
|
+
`${GROUP_ID_PREFIX}twitch:stream-inferred:${encodeURIComponent(anchoredPart.channelKey)}:${roundedAnchor}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const groupIdByRecordId = {};
|
|
286
|
+
const groupsById = {};
|
|
287
|
+
const aliasTargetsByGroupId = {};
|
|
288
|
+
const conflictCanonicalIds = new Set();
|
|
289
|
+
|
|
290
|
+
indexedRecords.forEach((record, index) => {
|
|
291
|
+
const root = find(index);
|
|
292
|
+
const groupId = canonicalGroupIdByRoot.get(root) ||
|
|
293
|
+
buildRecordTimeLabelStandaloneGroupId(record, platform, unknownTitle);
|
|
294
|
+
groupIdByRecordId[record.id] = groupId;
|
|
295
|
+
const createdAt = toCreatedAtMillis(record);
|
|
296
|
+
const existing = groupsById[groupId];
|
|
297
|
+
groupsById[groupId] = {
|
|
298
|
+
id: groupId,
|
|
299
|
+
latestCreatedAt: Math.max(existing?.latestCreatedAt || 0, createdAt),
|
|
300
|
+
recordIds: [...(existing?.recordIds || []), record.id]
|
|
301
|
+
};
|
|
302
|
+
if (conflictGroupIds.includes(root)) conflictCanonicalIds.add(groupId);
|
|
303
|
+
|
|
304
|
+
addAliasTarget(aliasTargetsByGroupId, groupId, groupId);
|
|
305
|
+
addAliasTarget(
|
|
306
|
+
aliasTargetsByGroupId,
|
|
307
|
+
buildRecordTimeLabelStandaloneGroupId(record, platform, unknownTitle),
|
|
308
|
+
groupId
|
|
309
|
+
);
|
|
310
|
+
addAliasTarget(aliasTargetsByGroupId, buildLegacyRecordTimeLabelGroupId(record, platform, unknownTitle), groupId);
|
|
311
|
+
addAliasTarget(aliasTargetsByGroupId, buildFallbackRecordGroupId(record, platform, unknownTitle), groupId);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const groups = Object.values(groupsById).sort((left, right) => {
|
|
315
|
+
const delta = (right.latestCreatedAt || 0) - (left.latestCreatedAt || 0);
|
|
316
|
+
if (delta !== 0) return delta;
|
|
317
|
+
return left.id.localeCompare(right.id);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
groupIdByRecordId,
|
|
322
|
+
groups,
|
|
323
|
+
aliasTargetsByGroupId,
|
|
324
|
+
diagnostics: {
|
|
325
|
+
identityVersion: RECORD_GROUP_IDENTITY_VERSION,
|
|
326
|
+
fallbackPlatform: platform,
|
|
327
|
+
recordCount: indexedRecords.length,
|
|
328
|
+
groupCount: groups.length,
|
|
329
|
+
skippedMissingIdCount,
|
|
330
|
+
skippedDuplicateIdCount,
|
|
331
|
+
conflictRefusalCount,
|
|
332
|
+
conflictGroupIds: [...conflictCanonicalIds].sort()
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const migrateGroupIdList = (groupIds = [], aliasTargetsByGroupId = {}, groupsById = {}) => {
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
const result = [];
|
|
340
|
+
normalizeIdList(groupIds).forEach((groupId) => {
|
|
341
|
+
const mappedGroupIds = aliasTargetsByGroupId[groupId] || [groupId];
|
|
342
|
+
const orderedGroupIds = mappedGroupIds.length > 1
|
|
343
|
+
? sortGroupIdsByLatestCreatedAt(mappedGroupIds, groupsById)
|
|
344
|
+
: mappedGroupIds;
|
|
345
|
+
orderedGroupIds.forEach((mappedGroupId) => {
|
|
346
|
+
if (!mappedGroupId || seen.has(mappedGroupId)) return;
|
|
347
|
+
seen.add(mappedGroupId);
|
|
348
|
+
result.push(mappedGroupId);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
return result;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const groupsByIdFromIndex = (groupIndex) => {
|
|
355
|
+
const groupsById = {};
|
|
356
|
+
(groupIndex?.groups || []).forEach((group) => {
|
|
357
|
+
if (group?.id) groupsById[group.id] = group;
|
|
358
|
+
});
|
|
359
|
+
return groupsById;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export const canonicalizeRecordTimeLabelGroupView = ({
|
|
363
|
+
records = [],
|
|
364
|
+
groupIndex = null,
|
|
365
|
+
groupOrder = [],
|
|
366
|
+
expandedGroups = [],
|
|
367
|
+
fallbackPlatform,
|
|
368
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
369
|
+
} = {}) => {
|
|
370
|
+
const index = groupIndex || buildRecordTimeLabelGroupIndex({
|
|
371
|
+
records,
|
|
372
|
+
fallbackPlatform,
|
|
373
|
+
unknownTitle
|
|
374
|
+
});
|
|
375
|
+
const groupsById = groupsByIdFromIndex(index);
|
|
376
|
+
const visibleGroupIds = (index.groups || []).map((group) => group.id);
|
|
377
|
+
const migratedCurrentOrder = migrateGroupIdList(
|
|
378
|
+
groupOrder,
|
|
379
|
+
index.aliasTargetsByGroupId,
|
|
380
|
+
groupsById
|
|
381
|
+
);
|
|
382
|
+
const migratedExpandedGroups = migrateGroupIdList(
|
|
383
|
+
expandedGroups,
|
|
384
|
+
index.aliasTargetsByGroupId,
|
|
385
|
+
groupsById
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
let nextGroupOrder = [];
|
|
389
|
+
if (visibleGroupIds.length === 0) {
|
|
390
|
+
nextGroupOrder = migratedCurrentOrder.filter((groupId) => groupId.startsWith(GROUP_ID_PREFIX));
|
|
391
|
+
} else if (migratedCurrentOrder.length === 0) {
|
|
392
|
+
nextGroupOrder = sortGroupIdsByLatestCreatedAt(visibleGroupIds, groupsById);
|
|
393
|
+
} else {
|
|
394
|
+
const orderedVisibleGroups = migratedCurrentOrder.filter((groupId) => groupsById[groupId]);
|
|
395
|
+
const orderedVisibleSet = new Set(orderedVisibleGroups);
|
|
396
|
+
const newVisibleGroups = sortGroupIdsByLatestCreatedAt(
|
|
397
|
+
visibleGroupIds.filter((groupId) => !orderedVisibleSet.has(groupId)),
|
|
398
|
+
groupsById
|
|
399
|
+
);
|
|
400
|
+
const visibleSet = new Set([...newVisibleGroups, ...orderedVisibleGroups]);
|
|
401
|
+
const hiddenOrder = migratedCurrentOrder.filter((groupId) => (
|
|
402
|
+
groupId.startsWith(GROUP_ID_PREFIX) && !visibleSet.has(groupId)
|
|
403
|
+
));
|
|
404
|
+
nextGroupOrder = [...newVisibleGroups, ...orderedVisibleGroups, ...hiddenOrder];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const previousGroupOrder = normalizeIdList(groupOrder);
|
|
408
|
+
const previousExpanded = normalizeIdList(expandedGroups);
|
|
409
|
+
const changed = previousGroupOrder.join('\u0000') !== nextGroupOrder.join('\u0000') ||
|
|
410
|
+
previousExpanded.join('\u0000') !== migratedExpandedGroups.join('\u0000');
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
groupOrder: nextGroupOrder,
|
|
414
|
+
expandedGroups: migratedExpandedGroups,
|
|
415
|
+
changed,
|
|
416
|
+
diagnostics: {
|
|
417
|
+
identityVersion: RECORD_GROUP_IDENTITY_VERSION,
|
|
418
|
+
migratedAliasCount: previousGroupOrder.filter((groupId) => (
|
|
419
|
+
Boolean(index.aliasTargetsByGroupId[groupId]) &&
|
|
420
|
+
index.aliasTargetsByGroupId[groupId].join('\u0000') !== groupId
|
|
421
|
+
)).length,
|
|
422
|
+
newVisibleCount: nextGroupOrder.filter((groupId) => groupsById[groupId] && !previousGroupOrder.includes(groupId)).length,
|
|
423
|
+
retainedHiddenCount: nextGroupOrder.filter((groupId) => !groupsById[groupId]).length,
|
|
424
|
+
changed
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const buildRecordTimeLabelGroupMetadata = ({
|
|
430
|
+
records = [],
|
|
431
|
+
fallbackPlatform,
|
|
432
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE,
|
|
433
|
+
unknownChannel = DEFAULT_UNKNOWN_CHANNEL
|
|
434
|
+
} = {}) => {
|
|
435
|
+
const platform = normalizeFallbackPlatform(fallbackPlatform);
|
|
436
|
+
let latestRecord = null;
|
|
437
|
+
let latestCreatedAt = Number.NEGATIVE_INFINITY;
|
|
438
|
+
collectNonVirtualRecords(records).forEach((record) => {
|
|
439
|
+
const createdAt = toCreatedAtMillis(record);
|
|
440
|
+
if (!latestRecord || createdAt >= latestCreatedAt) {
|
|
441
|
+
latestRecord = record;
|
|
442
|
+
latestCreatedAt = createdAt;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
const safeRecord = latestRecord || {};
|
|
446
|
+
return {
|
|
447
|
+
title: getRecordTitle(safeRecord, unknownTitle),
|
|
448
|
+
streamerName: normalizeText(safeRecord.streamerName || safeRecord.channelName) || unknownChannel,
|
|
449
|
+
avatarUrl: safeRecord.avatarUrl || null,
|
|
450
|
+
platform: normalizeRecordPlatform(safeRecord, platform)
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
export const buildRecordTimeLabelGroupReorderOperationId = (previousOrder = [], nextOrder = []) => (
|
|
455
|
+
`group.reorder:canonical:${fingerprintCanonicalJson(normalizeIdList(previousOrder))}:${fingerprintCanonicalJson(normalizeIdList(nextOrder))}`
|
|
456
|
+
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const K = new Uint32Array([
|
|
2
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
3
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
4
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
5
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
6
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
7
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
8
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
9
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const rotr = (value, bits) => (value >>> bits) | (value << (32 - bits));
|
|
13
|
+
|
|
14
|
+
const toUtf8Bytes = (value) => {
|
|
15
|
+
const text = String(value);
|
|
16
|
+
if (typeof TextEncoder === 'function') {
|
|
17
|
+
return new TextEncoder().encode(text);
|
|
18
|
+
}
|
|
19
|
+
const bytes = [];
|
|
20
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
21
|
+
let code = text.charCodeAt(index);
|
|
22
|
+
if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
|
|
23
|
+
const next = text.charCodeAt(index + 1);
|
|
24
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
25
|
+
code = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
|
|
26
|
+
index += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (code < 0x80) bytes.push(code);
|
|
30
|
+
else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
|
31
|
+
else if (code < 0x10000) {
|
|
32
|
+
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
33
|
+
} else {
|
|
34
|
+
bytes.push(
|
|
35
|
+
0xf0 | (code >> 18),
|
|
36
|
+
0x80 | ((code >> 12) & 0x3f),
|
|
37
|
+
0x80 | ((code >> 6) & 0x3f),
|
|
38
|
+
0x80 | (code & 0x3f)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Uint8Array.from(bytes);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const sha256Hex = (value) => {
|
|
46
|
+
const message = toUtf8Bytes(value);
|
|
47
|
+
const bitLength = message.length * 8;
|
|
48
|
+
const paddedLength = (((message.length + 9 + 63) >> 6) << 6);
|
|
49
|
+
const bytes = new Uint8Array(paddedLength);
|
|
50
|
+
bytes.set(message);
|
|
51
|
+
bytes[message.length] = 0x80;
|
|
52
|
+
const view = new DataView(bytes.buffer);
|
|
53
|
+
view.setUint32(paddedLength - 4, bitLength >>> 0);
|
|
54
|
+
|
|
55
|
+
let h0 = 0x6a09e667;
|
|
56
|
+
let h1 = 0xbb67ae85;
|
|
57
|
+
let h2 = 0x3c6ef372;
|
|
58
|
+
let h3 = 0xa54ff53a;
|
|
59
|
+
let h4 = 0x510e527f;
|
|
60
|
+
let h5 = 0x9b05688c;
|
|
61
|
+
let h6 = 0x1f83d9ab;
|
|
62
|
+
let h7 = 0x5be0cd19;
|
|
63
|
+
const w = new Uint32Array(64);
|
|
64
|
+
|
|
65
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
66
|
+
for (let index = 0; index < 16; index += 1) {
|
|
67
|
+
w[index] = view.getUint32(offset + (index * 4));
|
|
68
|
+
}
|
|
69
|
+
for (let index = 16; index < 64; index += 1) {
|
|
70
|
+
const s0 = rotr(w[index - 15], 7) ^ rotr(w[index - 15], 18) ^ (w[index - 15] >>> 3);
|
|
71
|
+
const s1 = rotr(w[index - 2], 17) ^ rotr(w[index - 2], 19) ^ (w[index - 2] >>> 10);
|
|
72
|
+
w[index] = (w[index - 16] + s0 + w[index - 7] + s1) >>> 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let a = h0;
|
|
76
|
+
let b = h1;
|
|
77
|
+
let c = h2;
|
|
78
|
+
let d = h3;
|
|
79
|
+
let e = h4;
|
|
80
|
+
let f = h5;
|
|
81
|
+
let g = h6;
|
|
82
|
+
let h = h7;
|
|
83
|
+
for (let index = 0; index < 64; index += 1) {
|
|
84
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
85
|
+
const ch = (e & f) ^ (~e & g);
|
|
86
|
+
const temp1 = (h + S1 + ch + K[index] + w[index]) >>> 0;
|
|
87
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
88
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
89
|
+
const temp2 = (S0 + maj) >>> 0;
|
|
90
|
+
h = g;
|
|
91
|
+
g = f;
|
|
92
|
+
f = e;
|
|
93
|
+
e = (d + temp1) >>> 0;
|
|
94
|
+
d = c;
|
|
95
|
+
c = b;
|
|
96
|
+
b = a;
|
|
97
|
+
a = (temp1 + temp2) >>> 0;
|
|
98
|
+
}
|
|
99
|
+
h0 = (h0 + a) >>> 0;
|
|
100
|
+
h1 = (h1 + b) >>> 0;
|
|
101
|
+
h2 = (h2 + c) >>> 0;
|
|
102
|
+
h3 = (h3 + d) >>> 0;
|
|
103
|
+
h4 = (h4 + e) >>> 0;
|
|
104
|
+
h5 = (h5 + f) >>> 0;
|
|
105
|
+
h6 = (h6 + g) >>> 0;
|
|
106
|
+
h7 = (h7 + h) >>> 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [h0, h1, h2, h3, h4, h5, h6, h7]
|
|
110
|
+
.map((word) => word.toString(16).padStart(8, '0'))
|
|
111
|
+
.join('');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const canonicalizeJson = (value) => {
|
|
115
|
+
if (value === null) return 'null';
|
|
116
|
+
const type = typeof value;
|
|
117
|
+
if (type === 'number') {
|
|
118
|
+
if (!Number.isFinite(value)) return 'null';
|
|
119
|
+
return JSON.stringify(value);
|
|
120
|
+
}
|
|
121
|
+
if (type === 'boolean') return value ? 'true' : 'false';
|
|
122
|
+
if (type === 'string') return JSON.stringify(value);
|
|
123
|
+
if (type !== 'object') return undefined;
|
|
124
|
+
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
const items = value.map((entry) => {
|
|
127
|
+
const serialized = canonicalizeJson(entry);
|
|
128
|
+
return serialized === undefined ? 'null' : serialized;
|
|
129
|
+
});
|
|
130
|
+
return `[${items.join(',')}]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
|
|
134
|
+
const fields = [];
|
|
135
|
+
keys.forEach((key) => {
|
|
136
|
+
const serialized = canonicalizeJson(value[key]);
|
|
137
|
+
if (serialized === undefined) return;
|
|
138
|
+
fields.push(`${JSON.stringify(key)}:${serialized}`);
|
|
139
|
+
});
|
|
140
|
+
return `{${fields.join(',')}}`;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const digestCanonicalJson = (value) => sha256Hex(canonicalizeJson(value));
|
|
144
|
+
|
|
145
|
+
export const fingerprintCanonicalJson = (value, length = 16) => (
|
|
146
|
+
digestCanonicalJson(value).slice(0, length)
|
|
147
|
+
);
|