@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
package/README.md
CHANGED
|
@@ -75,11 +75,15 @@ If this checkout's `package.json` is ahead of the published version, publish the
|
|
|
75
75
|
- `getActiveTrashEntries(entries, now)`
|
|
76
76
|
- `RTL_TRASH_RETENTION_MS`
|
|
77
77
|
|
|
78
|
-
The package exports
|
|
78
|
+
The package exports five intentional entrypoints. The root (`@recordtimelabel/core`)
|
|
79
79
|
keeps the complete backwards-compatible surface, `/protocol` contains only shared
|
|
80
80
|
acknowledgement protocol helpers, `/firestore-v2` contains platform-neutral document
|
|
81
|
-
and operation planners,
|
|
82
|
-
`
|
|
81
|
+
and operation planners, `/domain` contains group identity, Twitch VOD matching,
|
|
82
|
+
legacy import, and channel-folder planning, and `/compat` contains the legacy
|
|
83
|
+
`createSyncEngine` and `createRecordTimeLabelController` APIs.
|
|
84
|
+
|
|
85
|
+
`RECORD_TIMELABEL_DOMAIN_CAPABILITIES` is a local release contract. It is not a
|
|
86
|
+
gateway protocol capability.
|
|
83
87
|
|
|
84
88
|
### Durable sync engine
|
|
85
89
|
|
|
@@ -131,8 +135,16 @@ content is quarantined as an ID conflict. Rejected entries are terminal diagnost
|
|
|
131
135
|
API; an intentional retry currently requires a new operation ID.
|
|
132
136
|
|
|
133
137
|
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`,
|
|
134
|
-
`syncUntilIdle(reason, options)`, `waitForIdle()`, `
|
|
135
|
-
and `destroy()`.
|
|
138
|
+
`syncUntilIdle(reason, options)`, `waitForIdle()`, `waitForRemoteReady()`, `getSnapshot()`,
|
|
139
|
+
`subscribe(listener)`, and `destroy()`. A cloud `subscribe(listener, context)` disposer may
|
|
140
|
+
carry a `.ready` promise. After `init()` settles, hosts can await `waitForRemoteReady()` to
|
|
141
|
+
prove that the current subscription's initial root observation and any bounded catch-up have
|
|
142
|
+
settled; this wait rejects when the listener fails before readiness and fails closed across a
|
|
143
|
+
session generation change or destroy. `init()` deliberately does not await this promise because
|
|
144
|
+
the initial listener callback may itself enter the engine queue. Anonymous/no-subscription use
|
|
145
|
+
is immediately ready.
|
|
146
|
+
|
|
147
|
+
`sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
|
|
136
148
|
subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
|
|
137
149
|
conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
|
|
138
150
|
reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recordtimelabel/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./protocol": "./src/protocol.js",
|
|
10
10
|
"./firestore-v2": "./src/firestore-v2.js",
|
|
11
|
-
"./compat": "./src/compat.js"
|
|
11
|
+
"./compat": "./src/compat.js",
|
|
12
|
+
"./domain": "./src/domain.js"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|
|
14
15
|
"test": "node --test tests/*.test.mjs"
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SYSTEM_FOLDER_IDS,
|
|
3
|
+
normalizeId,
|
|
4
|
+
normalizeText,
|
|
5
|
+
toArray
|
|
6
|
+
} from './shared.js';
|
|
7
|
+
|
|
8
|
+
const FUZZY_MATCH_THRESHOLD = 0.88;
|
|
9
|
+
const INVALID_CHANNEL_NAMES = new Set([
|
|
10
|
+
'',
|
|
11
|
+
'n/a',
|
|
12
|
+
'na',
|
|
13
|
+
'unknown',
|
|
14
|
+
'unknown channel',
|
|
15
|
+
'unknown streamer',
|
|
16
|
+
'fallback',
|
|
17
|
+
'未知頻道',
|
|
18
|
+
'未知频道'
|
|
19
|
+
]);
|
|
20
|
+
const RESERVED_TWITCH_PATHS = new Set([
|
|
21
|
+
'about', 'clips', 'directory', 'downloads', 'followers', 'following', 'jobs',
|
|
22
|
+
'p', 'profile', 'schedule', 'settings', 'store', 'subscriptions', 'videos'
|
|
23
|
+
]);
|
|
24
|
+
const CHANNEL_STOP_WORDS = new Set([
|
|
25
|
+
'ch', 'channel', 'channels', 'official', 'stream', 'streamer', 'streams',
|
|
26
|
+
'twitch', 'video', 'videos', 'vod', 'vods', 'youtube', 'yt'
|
|
27
|
+
]);
|
|
28
|
+
const EVIDENCE_SOURCE_RANK = {
|
|
29
|
+
folderName: 0,
|
|
30
|
+
folderAlias: 1,
|
|
31
|
+
folderAliasName: 1,
|
|
32
|
+
folderAliasHandle: 1,
|
|
33
|
+
folderAliasUrl: 1,
|
|
34
|
+
record: 2
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const toText = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
38
|
+
|
|
39
|
+
export const normalizeChannelPlatform = (value) => {
|
|
40
|
+
const platform = toText(value).toLowerCase();
|
|
41
|
+
if (platform.includes('youtube') || platform === 'yt') return 'youtube';
|
|
42
|
+
if (platform.includes('twitch')) return 'twitch';
|
|
43
|
+
return platform || '';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const isValidChannelName = (value) => {
|
|
47
|
+
const text = toText(value);
|
|
48
|
+
if (!text) return false;
|
|
49
|
+
const normalized = text.normalize('NFKC').toLowerCase();
|
|
50
|
+
if (INVALID_CHANNEL_NAMES.has(normalized)) return false;
|
|
51
|
+
return !/unknown|fallback/i.test(normalized);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const splitChannelTokens = (value, {removeStopWords = true} = {}) => {
|
|
55
|
+
const normalized = toText(value)
|
|
56
|
+
.normalize('NFKC')
|
|
57
|
+
.replace(/^@+/, '')
|
|
58
|
+
.replace(/['"`]/g, '')
|
|
59
|
+
.replace(/[()[\]{}]/g, ' ')
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
return normalized
|
|
62
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
63
|
+
.map((token) => token.trim())
|
|
64
|
+
.filter((token) => token && (!removeStopWords || !CHANNEL_STOP_WORDS.has(token)));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const getChannelMatchKey = (value) => splitChannelTokens(value).join('');
|
|
68
|
+
|
|
69
|
+
export const getStrictChannelMatchKey = (value) => (
|
|
70
|
+
splitChannelTokens(value, {removeStopWords: false}).join('')
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const getCandidateKey = (value) => {
|
|
74
|
+
const key = getChannelMatchKey(value);
|
|
75
|
+
if (key) return key;
|
|
76
|
+
return toText(value)
|
|
77
|
+
.normalize('NFKC')
|
|
78
|
+
.replace(/^@+/, '')
|
|
79
|
+
.replace(/[^\p{L}\p{N}]+/gu, '')
|
|
80
|
+
.toLowerCase();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const evidenceSourceRank = (source) => {
|
|
84
|
+
const text = String(source || '');
|
|
85
|
+
if (Object.hasOwn(EVIDENCE_SOURCE_RANK, text)) return EVIDENCE_SOURCE_RANK[text];
|
|
86
|
+
if (text.startsWith('record:')) return EVIDENCE_SOURCE_RANK.record;
|
|
87
|
+
return 3;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const addCandidate = (candidates, value, source) => {
|
|
91
|
+
if (!isValidChannelName(value)) return;
|
|
92
|
+
const text = toText(value);
|
|
93
|
+
const key = getCandidateKey(text);
|
|
94
|
+
if (!key) return;
|
|
95
|
+
const dedupeKey = `${source}:${key}`;
|
|
96
|
+
if (candidates.some((candidate) => candidate.dedupeKey === dedupeKey)) return;
|
|
97
|
+
candidates.push({
|
|
98
|
+
value: text,
|
|
99
|
+
key,
|
|
100
|
+
strictKey: getStrictChannelMatchKey(text),
|
|
101
|
+
source,
|
|
102
|
+
dedupeKey
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const parseUrl = (value) => {
|
|
107
|
+
const text = toText(value);
|
|
108
|
+
if (!text) return null;
|
|
109
|
+
try {
|
|
110
|
+
return new URL(text);
|
|
111
|
+
} catch (_) {
|
|
112
|
+
try {
|
|
113
|
+
return new URL(`https://${text}`);
|
|
114
|
+
} catch (__) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const extractChannelHandleFromUrl = (value) => {
|
|
121
|
+
const url = parseUrl(value);
|
|
122
|
+
if (!url) return '';
|
|
123
|
+
const host = url.hostname.replace(/^www\./, '').toLowerCase();
|
|
124
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
125
|
+
if (parts.length === 0) return '';
|
|
126
|
+
if (host.endsWith('twitch.tv')) {
|
|
127
|
+
const first = parts[0].toLowerCase();
|
|
128
|
+
if (RESERVED_TWITCH_PATHS.has(first)) return '';
|
|
129
|
+
return parts[0];
|
|
130
|
+
}
|
|
131
|
+
if (host.endsWith('youtube.com')) {
|
|
132
|
+
if (parts[0]?.startsWith('@')) return parts[0];
|
|
133
|
+
if (['c', 'user'].includes(parts[0]) && parts[1]) return parts[1];
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
return '';
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const collectRecordTimeLabelChannelCandidates = (record = {}) => {
|
|
140
|
+
const candidates = [];
|
|
141
|
+
addCandidate(candidates, record.streamerName, 'streamerName');
|
|
142
|
+
addCandidate(candidates, record.channelName, 'channelName');
|
|
143
|
+
addCandidate(candidates, record.displayName, 'displayName');
|
|
144
|
+
addCandidate(candidates, record.channelHandle, 'channelHandle');
|
|
145
|
+
addCandidate(candidates, record.channelUsername, 'channelUsername');
|
|
146
|
+
addCandidate(candidates, record.username, 'username');
|
|
147
|
+
addCandidate(candidates, record.userName, 'userName');
|
|
148
|
+
[
|
|
149
|
+
record.channelUrl,
|
|
150
|
+
record.channelBaseUrl,
|
|
151
|
+
record.videoUrl,
|
|
152
|
+
record.originalVideoUrl,
|
|
153
|
+
record.jumpUrl
|
|
154
|
+
].forEach((url) => {
|
|
155
|
+
addCandidate(candidates, extractChannelHandleFromUrl(url), 'urlHandle');
|
|
156
|
+
});
|
|
157
|
+
return candidates.map(({dedupeKey: _dedupeKey, ...candidate}) => candidate);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const collectFolderChannelCandidates = (folder = {}, folderRecords = []) => {
|
|
161
|
+
const candidates = [];
|
|
162
|
+
addCandidate(candidates, folder.name, 'folderName');
|
|
163
|
+
if (Array.isArray(folder.aliases)) {
|
|
164
|
+
folder.aliases.forEach((alias) => {
|
|
165
|
+
if (typeof alias === 'string') addCandidate(candidates, alias, 'folderAlias');
|
|
166
|
+
else if (alias && typeof alias === 'object') {
|
|
167
|
+
addCandidate(candidates, alias.name, 'folderAliasName');
|
|
168
|
+
addCandidate(candidates, alias.handle, 'folderAliasHandle');
|
|
169
|
+
addCandidate(candidates, alias.url, 'folderAliasUrl');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
folderRecords.forEach((record) => {
|
|
174
|
+
collectRecordTimeLabelChannelCandidates(record).forEach((candidate) => {
|
|
175
|
+
addCandidate(candidates, candidate.value, `record:${candidate.source}`);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
return candidates;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const collectFolderPlatforms = (folderRecords = []) => {
|
|
182
|
+
const platforms = new Set();
|
|
183
|
+
folderRecords.forEach((record) => {
|
|
184
|
+
const platform = normalizeChannelPlatform(record?.platform);
|
|
185
|
+
if (platform) platforms.add(platform);
|
|
186
|
+
});
|
|
187
|
+
return platforms;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const jaroWinkler = (first, second) => {
|
|
191
|
+
if (first === second) return 1;
|
|
192
|
+
if (!first || !second) return 0;
|
|
193
|
+
const firstLength = first.length;
|
|
194
|
+
const secondLength = second.length;
|
|
195
|
+
const matchDistance = Math.floor(Math.max(firstLength, secondLength) / 2) - 1;
|
|
196
|
+
const firstMatches = new Array(firstLength).fill(false);
|
|
197
|
+
const secondMatches = new Array(secondLength).fill(false);
|
|
198
|
+
let matches = 0;
|
|
199
|
+
for (let i = 0; i < firstLength; i += 1) {
|
|
200
|
+
const start = Math.max(0, i - matchDistance);
|
|
201
|
+
const end = Math.min(i + matchDistance + 1, secondLength);
|
|
202
|
+
for (let j = start; j < end; j += 1) {
|
|
203
|
+
if (secondMatches[j] || first[i] !== second[j]) continue;
|
|
204
|
+
firstMatches[i] = true;
|
|
205
|
+
secondMatches[j] = true;
|
|
206
|
+
matches += 1;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (matches === 0) return 0;
|
|
211
|
+
let transpositions = 0;
|
|
212
|
+
let secondIndex = 0;
|
|
213
|
+
for (let i = 0; i < firstLength; i += 1) {
|
|
214
|
+
if (!firstMatches[i]) continue;
|
|
215
|
+
while (!secondMatches[secondIndex]) secondIndex += 1;
|
|
216
|
+
if (first[i] !== second[secondIndex]) transpositions += 1;
|
|
217
|
+
secondIndex += 1;
|
|
218
|
+
}
|
|
219
|
+
const jaro = (
|
|
220
|
+
(matches / firstLength) +
|
|
221
|
+
(matches / secondLength) +
|
|
222
|
+
((matches - (transpositions / 2)) / matches)
|
|
223
|
+
) / 3;
|
|
224
|
+
let prefix = 0;
|
|
225
|
+
const maxPrefix = Math.min(4, firstLength, secondLength);
|
|
226
|
+
while (prefix < maxPrefix && first[prefix] === second[prefix]) prefix += 1;
|
|
227
|
+
return jaro + (prefix * 0.1 * (1 - jaro));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const compareCandidates = (incoming, target) => {
|
|
231
|
+
if (!incoming?.key || !target?.key) return {score: 0, reason: 'empty'};
|
|
232
|
+
if (incoming.strictKey && incoming.strictKey === target.strictKey) {
|
|
233
|
+
return {score: 1, reason: 'exact-key'};
|
|
234
|
+
}
|
|
235
|
+
if (incoming.key === target.key) return {score: 0.94, reason: 'normalized-key'};
|
|
236
|
+
const shorter = incoming.key.length <= target.key.length ? incoming.key : target.key;
|
|
237
|
+
const longer = incoming.key.length > target.key.length ? incoming.key : target.key;
|
|
238
|
+
if (shorter.length >= 6 && longer.includes(shorter) && (shorter.length / longer.length) >= 0.65) {
|
|
239
|
+
return {score: 0.93, reason: 'contained-key'};
|
|
240
|
+
}
|
|
241
|
+
return {score: jaroWinkler(incoming.key, target.key), reason: 'jaro-winkler'};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const folderAllowsFuzzyMatch = (incomingPlatform, folderPlatforms) => {
|
|
245
|
+
if (!incomingPlatform || folderPlatforms.size === 0) return false;
|
|
246
|
+
return [...folderPlatforms].some((platform) => platform !== incomingPlatform);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const isBetterMatch = (candidate, current, folderOrderIndex) => {
|
|
250
|
+
if (!current) return true;
|
|
251
|
+
if (candidate.score !== current.score) return candidate.score > current.score;
|
|
252
|
+
const candidateEvidence = evidenceSourceRank(candidate.evidenceSource);
|
|
253
|
+
const currentEvidence = evidenceSourceRank(current.evidenceSource);
|
|
254
|
+
if (candidateEvidence !== currentEvidence) return candidateEvidence < currentEvidence;
|
|
255
|
+
const candidateOrder = folderOrderIndex.get(candidate.folderId) ?? Number.POSITIVE_INFINITY;
|
|
256
|
+
const currentOrder = folderOrderIndex.get(current.folderId) ?? Number.POSITIVE_INFINITY;
|
|
257
|
+
if (candidateOrder !== currentOrder) return candidateOrder < currentOrder;
|
|
258
|
+
return String(candidate.folderId).localeCompare(String(current.folderId)) < 0;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const findBestRecordTimeLabelChannelFolder = ({
|
|
262
|
+
record,
|
|
263
|
+
folders = [],
|
|
264
|
+
recordsByFolder = {},
|
|
265
|
+
folderOrder = [],
|
|
266
|
+
excludedFolderIds = SYSTEM_FOLDER_IDS,
|
|
267
|
+
fuzzyThreshold = FUZZY_MATCH_THRESHOLD
|
|
268
|
+
} = {}) => {
|
|
269
|
+
const incomingCandidates = collectRecordTimeLabelChannelCandidates(record);
|
|
270
|
+
if (incomingCandidates.length === 0) return null;
|
|
271
|
+
|
|
272
|
+
const incomingPlatform = normalizeChannelPlatform(record?.platform);
|
|
273
|
+
const excludedIds = excludedFolderIds instanceof Set
|
|
274
|
+
? excludedFolderIds
|
|
275
|
+
: new Set(excludedFolderIds || []);
|
|
276
|
+
const folderOrderIndex = new Map(
|
|
277
|
+
(Array.isArray(folderOrder) ? folderOrder : []).map((folderId, index) => [folderId, index])
|
|
278
|
+
);
|
|
279
|
+
(folders || []).forEach((folder, index) => {
|
|
280
|
+
if (folder?.id && !folderOrderIndex.has(folder.id)) folderOrderIndex.set(folder.id, 100000 + index);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let bestMatch = null;
|
|
284
|
+
(folders || []).forEach((folder) => {
|
|
285
|
+
if (!folder?.id || excludedIds.has(folder.id) || SYSTEM_FOLDER_IDS.has(folder.id)) return;
|
|
286
|
+
const folderRecords = Array.isArray(recordsByFolder?.[folder.id]) ? recordsByFolder[folder.id] : [];
|
|
287
|
+
const folderPlatforms = collectFolderPlatforms(folderRecords);
|
|
288
|
+
const allowFuzzy = folderAllowsFuzzyMatch(incomingPlatform, folderPlatforms);
|
|
289
|
+
const targetCandidates = collectFolderChannelCandidates(folder, folderRecords);
|
|
290
|
+
|
|
291
|
+
incomingCandidates.forEach((incomingCandidate) => {
|
|
292
|
+
targetCandidates.forEach((targetCandidate) => {
|
|
293
|
+
const comparison = compareCandidates(incomingCandidate, targetCandidate);
|
|
294
|
+
const isExact = comparison.score === 1;
|
|
295
|
+
const isAcceptedFuzzy = allowFuzzy && comparison.score >= fuzzyThreshold;
|
|
296
|
+
if (!isExact && !isAcceptedFuzzy) return;
|
|
297
|
+
const match = {
|
|
298
|
+
folderId: folder.id,
|
|
299
|
+
folder: {id: folder.id, name: folder.name || ''},
|
|
300
|
+
score: comparison.score,
|
|
301
|
+
reason: comparison.reason,
|
|
302
|
+
evidenceSource: targetCandidate.source,
|
|
303
|
+
incomingCandidate,
|
|
304
|
+
targetCandidate: {
|
|
305
|
+
value: targetCandidate.value,
|
|
306
|
+
key: targetCandidate.key,
|
|
307
|
+
strictKey: targetCandidate.strictKey,
|
|
308
|
+
source: targetCandidate.source
|
|
309
|
+
},
|
|
310
|
+
crossPlatform: allowFuzzy,
|
|
311
|
+
folderPlatforms: [...folderPlatforms]
|
|
312
|
+
};
|
|
313
|
+
if (isBetterMatch(match, bestMatch, folderOrderIndex)) bestMatch = match;
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return bestMatch;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export const isPendingChannelFolderId = (folderId) => (
|
|
322
|
+
typeof folderId === 'string' && folderId.startsWith('pending-channel-folder-')
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
export const buildRecordTimeLabelChannelFolderPlan = ({
|
|
326
|
+
channelGroups = {},
|
|
327
|
+
folders = [],
|
|
328
|
+
recordsByFolder = {},
|
|
329
|
+
folderOrder = []
|
|
330
|
+
} = {}) => {
|
|
331
|
+
const foldersToCreate = [];
|
|
332
|
+
const channelFolderAssignments = {};
|
|
333
|
+
const plannedFolders = [...(Array.isArray(folders) ? folders : [])];
|
|
334
|
+
const plannedRecords = {...recordsByFolder};
|
|
335
|
+
|
|
336
|
+
Object.entries(channelGroups || {}).forEach(([channelName, entries = []]) => {
|
|
337
|
+
const representativeRecord = {
|
|
338
|
+
...(entries[0]?.record || {}),
|
|
339
|
+
streamerName: channelName
|
|
340
|
+
};
|
|
341
|
+
const sourceFolderIds = new Set(entries.map((entry) => entry.currentFolderId).filter(Boolean));
|
|
342
|
+
const exactNameFolder = plannedFolders.find((folder) => (
|
|
343
|
+
folder?.id &&
|
|
344
|
+
!SYSTEM_FOLDER_IDS.has(folder.id) &&
|
|
345
|
+
normalizeText(folder.name).toLowerCase() === normalizeText(channelName).toLowerCase()
|
|
346
|
+
));
|
|
347
|
+
const matchedFolder = exactNameFolder || findBestRecordTimeLabelChannelFolder({
|
|
348
|
+
record: representativeRecord,
|
|
349
|
+
folders: plannedFolders.filter((folder) => !sourceFolderIds.has(folder?.id)),
|
|
350
|
+
recordsByFolder: plannedRecords,
|
|
351
|
+
folderOrder
|
|
352
|
+
})?.folder;
|
|
353
|
+
|
|
354
|
+
if (matchedFolder) {
|
|
355
|
+
channelFolderAssignments[channelName] = isPendingChannelFolderId(matchedFolder.id)
|
|
356
|
+
? {tempId: matchedFolder.id}
|
|
357
|
+
: {folderId: matchedFolder.id};
|
|
358
|
+
plannedRecords[matchedFolder.id] = [
|
|
359
|
+
...(plannedRecords[matchedFolder.id] || []),
|
|
360
|
+
...entries.map((entry) => entry.record)
|
|
361
|
+
];
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const tempId = `pending-channel-folder-${foldersToCreate.length}`;
|
|
366
|
+
foldersToCreate.push({channelName, tempId});
|
|
367
|
+
channelFolderAssignments[channelName] = {tempId};
|
|
368
|
+
plannedFolders.push({id: tempId, name: channelName});
|
|
369
|
+
plannedRecords[tempId] = entries.map((entry) => entry.record);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
foldersToCreate,
|
|
374
|
+
channelFolderAssignments,
|
|
375
|
+
plannedRecords
|
|
376
|
+
};
|
|
377
|
+
};
|