tt-help-cli-ycl 1.3.35 → 1.3.36

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.
@@ -1,6 +1,878 @@
1
1
  import fs from "fs";
2
- import { promises as fsPromises } from "fs";
3
2
  import path from "path";
3
+ import Database from "better-sqlite3";
4
+
5
+ // SQLite 用户表(用于判重)
6
+ let db = null;
7
+ let dbPath = null;
8
+
9
+ function normalizeDbFilePath(filePath) {
10
+ if (!filePath) {
11
+ throw new Error("db path is required");
12
+ }
13
+ const resolved = path.resolve(filePath);
14
+ if (path.extname(resolved).toLowerCase() !== ".db") {
15
+ throw new Error(`仅支持 .db 路径,当前为: ${filePath}`);
16
+ }
17
+ return resolved;
18
+ }
19
+
20
+ function resetDbConnection() {
21
+ if (db) {
22
+ db.close();
23
+ db = null;
24
+ }
25
+ dbPath = null;
26
+ }
27
+
28
+ function loadLegacyUsersFromFiles(userFilePath, doneFilePath) {
29
+ const merged = new Map();
30
+
31
+ const tryLoad = (targetPath, label) => {
32
+ if (!targetPath) return;
33
+ if (!fs.existsSync(targetPath)) return;
34
+ try {
35
+ const parsed = JSON.parse(fs.readFileSync(targetPath, "utf-8"));
36
+ if (!Array.isArray(parsed)) return;
37
+ for (const item of parsed) {
38
+ const uniqueId = item?.uniqueId || item?.unique_id;
39
+ if (!uniqueId) continue;
40
+ merged.set(uniqueId, {
41
+ ...merged.get(uniqueId),
42
+ ...item,
43
+ uniqueId,
44
+ });
45
+ }
46
+ } catch (e) {
47
+ console.error(`[data-store] SQLite 导入 ${label} 失败: ${e.message}`);
48
+ }
49
+ };
50
+
51
+ tryLoad(userFilePath, "result.json");
52
+ tryLoad(doneFilePath, "result-done.json");
53
+
54
+ return [...merged.values()];
55
+ }
56
+
57
+ function loadLegacyVideosFromFile(videoPath) {
58
+ if (!videoPath) return [];
59
+ if (!fs.existsSync(videoPath)) return [];
60
+
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(videoPath, "utf-8"));
63
+ return Array.isArray(parsed) ? parsed : [];
64
+ } catch (e) {
65
+ console.error(
66
+ `[data-store] SQLite 导入 result-videos.json 失败: ${e.message}`,
67
+ );
68
+ return [];
69
+ }
70
+ }
71
+
72
+ function initUserDb(filePath) {
73
+ dbPath = normalizeDbFilePath(filePath);
74
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
75
+ db = new Database(dbPath);
76
+ db.pragma("journal_mode = WAL");
77
+ db.exec(`
78
+ CREATE TABLE IF NOT EXISTS users (
79
+ unique_id TEXT PRIMARY KEY,
80
+ tt_seller TEXT,
81
+ verified INTEGER,
82
+ location_created TEXT,
83
+ created_at TEXT,
84
+ updated_at TEXT
85
+ )
86
+ `);
87
+ db.exec(`
88
+ CREATE TABLE IF NOT EXISTS jobs (
89
+ unique_id TEXT PRIMARY KEY,
90
+ nickname TEXT,
91
+ status TEXT DEFAULT 'pending',
92
+ sources TEXT,
93
+ claimed_by TEXT,
94
+ claimed_at INTEGER,
95
+ error TEXT,
96
+ pinned INTEGER DEFAULT 0,
97
+ no_video INTEGER DEFAULT 0,
98
+ restricted INTEGER DEFAULT 0,
99
+ user_update_count INTEGER DEFAULT 0,
100
+ tt_seller INTEGER,
101
+ verified INTEGER,
102
+ video_count INTEGER DEFAULT 0,
103
+ comment_count INTEGER DEFAULT 0,
104
+ guessed_location TEXT,
105
+ location_created TEXT,
106
+ follower_count INTEGER DEFAULT 0,
107
+ following_count INTEGER DEFAULT 0,
108
+ heart_count INTEGER DEFAULT 0,
109
+ refresh_time INTEGER,
110
+ processed INTEGER DEFAULT 0,
111
+ processed_at INTEGER,
112
+ created_at INTEGER,
113
+ updated_at INTEGER,
114
+ region TEXT,
115
+ signature TEXT,
116
+ sec_uid TEXT
117
+ )
118
+ `);
119
+ db.exec(`
120
+ CREATE TABLE IF NOT EXISTS videos (
121
+ id TEXT PRIMARY KEY,
122
+ href TEXT,
123
+ author_unique_id TEXT,
124
+ location_created TEXT,
125
+ tt_seller INTEGER DEFAULT 0,
126
+ registered_at INTEGER,
127
+ user_update_count INTEGER DEFAULT 0,
128
+ play_count INTEGER,
129
+ digg_count INTEGER,
130
+ comment_count INTEGER,
131
+ share_count INTEGER,
132
+ collect_count INTEGER,
133
+ stats_updated_at INTEGER
134
+ )
135
+ `);
136
+ db.exec(`
137
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_video
138
+ ON jobs(status, video_count DESC)
139
+ `);
140
+ db.exec(`
141
+ CREATE INDEX IF NOT EXISTS idx_jobs_claimed_by_status
142
+ ON jobs(claimed_by, status, claimed_at)
143
+ `);
144
+ db.exec(`
145
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_claimed_at
146
+ ON jobs(status, claimed_at)
147
+ `);
148
+ db.exec(`
149
+ CREATE INDEX IF NOT EXISTS idx_jobs_redo_target
150
+ ON jobs(tt_seller, verified, location_created, refresh_time)
151
+ `);
152
+ db.exec(`
153
+ CREATE INDEX IF NOT EXISTS idx_jobs_pending_priority
154
+ ON jobs(status, pinned DESC, guessed_location, follower_count DESC)
155
+ `);
156
+ db.exec(`
157
+ CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_pinned
158
+ ON jobs(created_at ASC, unique_id ASC)
159
+ WHERE status = 'pending' AND COALESCE(pinned, 0) = 1
160
+ `);
161
+ db.exec(`
162
+ CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_seller
163
+ ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
164
+ WHERE status = 'pending'
165
+ AND COALESCE(pinned, 0) = 0
166
+ AND tt_seller = 1
167
+ AND verified = 0
168
+ `);
169
+ db.exec(`
170
+ CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_follow
171
+ ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
172
+ WHERE status = 'pending'
173
+ AND COALESCE(pinned, 0) = 0
174
+ AND (
175
+ instr(COALESCE(sources, ''), '"following"') > 0
176
+ OR instr(COALESCE(sources, ''), '"follower"') > 0
177
+ )
178
+ `);
179
+ db.exec(`
180
+ CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_other
181
+ ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
182
+ WHERE status = 'pending' AND COALESCE(pinned, 0) = 0
183
+ `);
184
+ db.exec(`
185
+ CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue
186
+ ON jobs(created_at ASC, unique_id ASC)
187
+ WHERE (tt_seller IS NULL OR tt_seller = '')
188
+ AND (user_update_count IS NULL OR user_update_count <= 0)
189
+ `);
190
+ db.exec(`
191
+ CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue_expr
192
+ ON jobs(created_at ASC, unique_id ASC)
193
+ WHERE COALESCE(tt_seller, '') = ''
194
+ AND COALESCE(user_update_count, 0) <= 0
195
+ `);
196
+ db.exec(`
197
+ CREATE INDEX IF NOT EXISTS idx_videos_comment_queue
198
+ ON videos(user_update_count, tt_seller DESC, registered_at ASC)
199
+ `);
200
+ db.exec(`
201
+ CREATE INDEX IF NOT EXISTS idx_videos_comment_queue_pending
202
+ ON videos(tt_seller DESC, registered_at ASC, id)
203
+ WHERE user_update_count IS NULL OR user_update_count <= 0
204
+ `);
205
+
206
+ const existingVideoColumns = new Set(
207
+ db
208
+ .prepare("PRAGMA table_info(videos)")
209
+ .all()
210
+ .map((column) => column.name),
211
+ );
212
+ const requiredVideoColumns = {
213
+ play_count: "INTEGER",
214
+ digg_count: "INTEGER",
215
+ comment_count: "INTEGER",
216
+ share_count: "INTEGER",
217
+ collect_count: "INTEGER",
218
+ stats_updated_at: "INTEGER",
219
+ };
220
+ for (const [column, type] of Object.entries(requiredVideoColumns)) {
221
+ if (!existingVideoColumns.has(column)) {
222
+ db.exec(`ALTER TABLE videos ADD COLUMN ${column} ${type}`);
223
+ }
224
+ }
225
+
226
+ const count = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
227
+ console.log(`[data-store] SQLite users 表初始化完成: ${count} 条`);
228
+ }
229
+
230
+ export function importLegacyJsonToDb({
231
+ dbFilePath,
232
+ usersFilePath,
233
+ doneFilePath,
234
+ videosFilePath,
235
+ }) {
236
+ resetDbConnection();
237
+ initUserDb(dbFilePath);
238
+
239
+ const legacyUsers = loadLegacyUsersFromFiles(usersFilePath, doneFilePath);
240
+ const legacyVideos = loadLegacyVideosFromFile(videosFilePath);
241
+
242
+ const beforeUsers = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
243
+ const beforeJobs = db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
244
+ const beforeVideos = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
245
+
246
+ const insertUserStmt = db.prepare(`
247
+ INSERT OR IGNORE INTO users (unique_id) VALUES (?)
248
+ `);
249
+ const insertVideoStmt = db.prepare(`
250
+ INSERT OR IGNORE INTO videos (
251
+ id,
252
+ href,
253
+ author_unique_id,
254
+ location_created,
255
+ tt_seller,
256
+ registered_at,
257
+ user_update_count
258
+ )
259
+ VALUES (?, ?, ?, ?, ?, ?, ?)
260
+ `);
261
+
262
+ const importUsersTxn = db.transaction((items) => {
263
+ for (const item of items) {
264
+ const uniqueId = item.uniqueId || item.unique_id;
265
+ if (!uniqueId) continue;
266
+ insertUserStmt.run(uniqueId);
267
+ addJobToDb({ ...item, uniqueId });
268
+ }
269
+ });
270
+
271
+ const importVideosTxn = db.transaction((items) => {
272
+ for (const item of items) {
273
+ if (!item?.id) continue;
274
+ insertVideoStmt.run(
275
+ item.id,
276
+ item.href || null,
277
+ item.authorUniqueId || item.author_unique_id || null,
278
+ item.locationCreated || item.location_created || null,
279
+ item.ttSeller ? 1 : 0,
280
+ item.registeredAt || item.registered_at || Date.now(),
281
+ item.userUpdateCount || item.user_update_count || 0,
282
+ );
283
+ }
284
+ });
285
+
286
+ importUsersTxn(legacyUsers);
287
+ importVideosTxn(legacyVideos);
288
+
289
+ const afterUsers = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
290
+ const afterJobs = db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
291
+ const afterVideos = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
292
+
293
+ return {
294
+ dbPath,
295
+ usersImported: afterUsers - beforeUsers,
296
+ jobsImported: afterJobs - beforeJobs,
297
+ videosImported: afterVideos - beforeVideos,
298
+ totalUsers: afterUsers,
299
+ totalJobs: afterJobs,
300
+ totalVideos: afterVideos,
301
+ };
302
+ }
303
+
304
+ export function closeStoreDb() {
305
+ resetDbConnection();
306
+ }
307
+
308
+ function hasUserInDb(uid) {
309
+ if (!db) return false;
310
+ const row = db.prepare("SELECT 1 FROM users WHERE unique_id = ?").get(uid);
311
+ return !!row;
312
+ }
313
+
314
+ function addUserToDb(user) {
315
+ if (!db) return;
316
+ db.prepare(
317
+ `
318
+ INSERT OR IGNORE INTO users (unique_id, tt_seller, verified, location_created, created_at, updated_at)
319
+ VALUES (?, ?, ?, ?, ?, ?)
320
+ `,
321
+ ).run(
322
+ user.uniqueId,
323
+ user.ttSeller === undefined ||
324
+ user.ttSeller === null ||
325
+ user.ttSeller === ""
326
+ ? null
327
+ : user.ttSeller
328
+ ? 1
329
+ : 0,
330
+ user.verified === undefined ||
331
+ user.verified === null ||
332
+ user.verified === ""
333
+ ? null
334
+ : user.verified
335
+ ? 1
336
+ : 0,
337
+ user.locationCreated || null,
338
+ new Date().toISOString(),
339
+ new Date().toISOString(),
340
+ );
341
+ }
342
+
343
+ function addJobToDb(user) {
344
+ if (!db) return;
345
+ const now = Date.now();
346
+ db.prepare(
347
+ `
348
+ INSERT OR IGNORE INTO jobs (
349
+ unique_id,
350
+ nickname,
351
+ status,
352
+ sources,
353
+ claimed_by,
354
+ claimed_at,
355
+ error,
356
+ pinned,
357
+ no_video,
358
+ restricted,
359
+ user_update_count,
360
+ tt_seller,
361
+ verified,
362
+ video_count,
363
+ comment_count,
364
+ guessed_location,
365
+ location_created,
366
+ follower_count,
367
+ following_count,
368
+ heart_count,
369
+ refresh_time,
370
+ processed,
371
+ processed_at,
372
+ created_at,
373
+ updated_at,
374
+ region,
375
+ signature,
376
+ sec_uid
377
+ )
378
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
379
+ `,
380
+ ).run(
381
+ user.uniqueId,
382
+ user.nickname || null,
383
+ user.status || inferStatus(user),
384
+ JSON.stringify(
385
+ Array.isArray(user.sources) ? [...new Set(user.sources)] : [],
386
+ ),
387
+ user.claimedBy || null,
388
+ user.claimedAt || null,
389
+ user.error || null,
390
+ user.pinned ? 1 : 0,
391
+ user.noVideo ? 1 : 0,
392
+ user.restricted ? 1 : 0,
393
+ user.userUpdateCount || 0,
394
+ user.ttSeller === undefined ||
395
+ user.ttSeller === null ||
396
+ user.ttSeller === ""
397
+ ? null
398
+ : user.ttSeller
399
+ ? 1
400
+ : 0,
401
+ user.verified === undefined ||
402
+ user.verified === null ||
403
+ user.verified === ""
404
+ ? null
405
+ : user.verified
406
+ ? 1
407
+ : 0,
408
+ user.videoCount || 0,
409
+ user.commentCount || 0,
410
+ user.guessedLocation || null,
411
+ user.locationCreated || null,
412
+ user.followerCount || 0,
413
+ user.followingCount || 0,
414
+ user.heartCount || 0,
415
+ user.refreshTime || null,
416
+ user.processed ? 1 : 0,
417
+ user.processedAt || null,
418
+ user.createdAt || now,
419
+ user.updatedAt || now,
420
+ user.region || null,
421
+ user.signature || null,
422
+ user.secUid || null,
423
+ );
424
+ }
425
+
426
+ function getUserDbCount() {
427
+ if (!db) return 0;
428
+ return db.prepare("SELECT COUNT(*) as c FROM users").get().c;
429
+ }
430
+
431
+ function getJobsCount() {
432
+ if (!db) return 0;
433
+ return db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
434
+ }
435
+
436
+ function getPendingJobsCount() {
437
+ if (!db) return 0;
438
+ return db
439
+ .prepare("SELECT COUNT(*) as c FROM jobs WHERE status = 'pending'")
440
+ .get().c;
441
+ }
442
+
443
+ function getPendingJobsUserUpdateCount() {
444
+ if (!db) return 0;
445
+ return db
446
+ .prepare(
447
+ `
448
+ SELECT COUNT(*) as c
449
+ FROM jobs
450
+ WHERE COALESCE(tt_seller, '') = ''
451
+ AND COALESCE(user_update_count, 0) <= 0
452
+ `,
453
+ )
454
+ .get().c;
455
+ }
456
+
457
+ function getDashboardStatsFromDb(targetLocations = []) {
458
+ if (!db) return null;
459
+
460
+ const total = getJobsCount();
461
+ const statusCounts = {
462
+ pending: 0,
463
+ processing: 0,
464
+ done: 0,
465
+ error: 0,
466
+ restricted: 0,
467
+ };
468
+ const rows = db
469
+ .prepare(
470
+ `
471
+ SELECT status, COUNT(*) as count
472
+ FROM jobs
473
+ GROUP BY status
474
+ `,
475
+ )
476
+ .all();
477
+ for (const row of rows) {
478
+ if (row.status && statusCounts[row.status] !== undefined) {
479
+ statusCounts[row.status] = row.count;
480
+ }
481
+ }
482
+
483
+ const targetPlaceholders = targetLocations.map(() => "?").join(", ");
484
+ const targetUsers = targetLocations.length
485
+ ? db
486
+ .prepare(
487
+ `
488
+ SELECT COUNT(*) as c
489
+ FROM jobs
490
+ WHERE tt_seller = 1
491
+ AND verified = 0
492
+ AND location_created IN (${targetPlaceholders})
493
+ `,
494
+ )
495
+ .get(...targetLocations).c
496
+ : 0;
497
+
498
+ const countryStats = db
499
+ .prepare(
500
+ `
501
+ SELECT
502
+ COALESCE(location_created, '未知') as country,
503
+ COUNT(*) as count,
504
+ SUM(CASE
505
+ WHEN tt_seller = 1 AND verified = 0 ${
506
+ targetLocations.length
507
+ ? `AND location_created IN (${targetPlaceholders})`
508
+ : "AND 1 = 0"
509
+ }
510
+ THEN 1 ELSE 0 END) as targetCount
511
+ FROM jobs
512
+ WHERE status = 'done'
513
+ GROUP BY COALESCE(location_created, '未知')
514
+ ORDER BY count DESC
515
+ `,
516
+ )
517
+ .all(...targetLocations);
518
+
519
+ const targetCountryStats = targetLocations.length
520
+ ? db
521
+ .prepare(
522
+ `
523
+ SELECT location_created as country, COUNT(*) as count
524
+ FROM jobs
525
+ WHERE tt_seller = 1
526
+ AND verified = 0
527
+ AND location_created IN (${targetPlaceholders})
528
+ GROUP BY location_created
529
+ ORDER BY count DESC
530
+ `,
531
+ )
532
+ .all(...targetLocations)
533
+ : [];
534
+
535
+ const sourceRow = db
536
+ .prepare(
537
+ `
538
+ SELECT
539
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as processed,
540
+ SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
541
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
542
+ SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo,
543
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"video"') > 0 THEN 1 ELSE 0 END) as video,
544
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"comment"') > 0 THEN 1 ELSE 0 END) as comment,
545
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"guess"') > 0 THEN 1 ELSE 0 END) as guess,
546
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"following"') > 0 THEN 1 ELSE 0 END) as following,
547
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"follower"') > 0 THEN 1 ELSE 0 END) as follower,
548
+ SUM(CASE
549
+ WHEN status != 'done'
550
+ AND instr(COALESCE(sources, ''), '"video"') = 0
551
+ AND instr(COALESCE(sources, ''), '"comment"') = 0
552
+ AND instr(COALESCE(sources, ''), '"guess"') = 0
553
+ AND instr(COALESCE(sources, ''), '"following"') = 0
554
+ AND instr(COALESCE(sources, ''), '"follower"') = 0
555
+ THEN 1 ELSE 0 END) as seed
556
+ FROM jobs
557
+ `,
558
+ )
559
+ .get();
560
+
561
+ return {
562
+ totalUsers: total,
563
+ dbTotalUsers: getUserDbCount(),
564
+ jobsTotal: total,
565
+ jobsPending: getPendingJobsCount(),
566
+ processedUsers: statusCounts.done,
567
+ pendingUsers: statusCounts.pending,
568
+ processingUsers: statusCounts.processing,
569
+ restrictedUsers: statusCounts.restricted,
570
+ errorUsers: statusCounts.error,
571
+ targetUsers,
572
+ userUpdateTasks: getPendingJobsUserUpdateCount(),
573
+ targetCountryStats,
574
+ countryStats,
575
+ sourceStats: {
576
+ seed: sourceRow.seed || 0,
577
+ video: sourceRow.video || 0,
578
+ comment: sourceRow.comment || 0,
579
+ guess: sourceRow.guess || 0,
580
+ following: sourceRow.following || 0,
581
+ follower: sourceRow.follower || 0,
582
+ processed: sourceRow.processed || 0,
583
+ restricted: sourceRow.restricted || 0,
584
+ error: sourceRow.error || 0,
585
+ noVideo: sourceRow.noVideo || 0,
586
+ },
587
+ };
588
+ }
589
+
590
+ function getUsersPageFromDb({
591
+ status,
592
+ search,
593
+ location,
594
+ target,
595
+ targetLocation,
596
+ limit,
597
+ offset,
598
+ targetLocations = [],
599
+ }) {
600
+ if (!db) return null;
601
+
602
+ const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
603
+ const safeOffset = Math.max(0, parseInt(offset) || 0);
604
+ const where = [];
605
+ const args = [];
606
+
607
+ if (status && status !== "all") {
608
+ where.push("status = ?");
609
+ args.push(status);
610
+ }
611
+ if (target === "1") {
612
+ if (targetLocation) {
613
+ where.push("tt_seller = 1 AND verified = 0 AND location_created = ?");
614
+ args.push(targetLocation);
615
+ } else if (targetLocations.length > 0) {
616
+ where.push(
617
+ `tt_seller = 1 AND verified = 0 AND location_created IN (${targetLocations
618
+ .map(() => "?")
619
+ .join(", ")})`,
620
+ );
621
+ args.push(...targetLocations);
622
+ } else {
623
+ where.push("1 = 0");
624
+ }
625
+ }
626
+ if (search) {
627
+ where.push(
628
+ "(LOWER(unique_id) LIKE ? OR LOWER(COALESCE(nickname, '')) LIKE ?)",
629
+ );
630
+ const pattern = `%${String(search).toLowerCase()}%`;
631
+ args.push(pattern, pattern);
632
+ }
633
+ if (location) {
634
+ where.push("location_created = ?");
635
+ args.push(location);
636
+ }
637
+
638
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
639
+ const total = db
640
+ .prepare(`SELECT COUNT(*) as c FROM jobs ${whereSql}`)
641
+ .get(...args).c;
642
+
643
+ const rows = db
644
+ .prepare(
645
+ `
646
+ SELECT *
647
+ FROM jobs
648
+ ${whereSql}
649
+ ORDER BY
650
+ pinned DESC,
651
+ CASE status
652
+ WHEN 'processing' THEN 0
653
+ WHEN 'pending' THEN 1
654
+ WHEN 'done' THEN 2
655
+ WHEN 'error' THEN 3
656
+ WHEN 'restricted' THEN 4
657
+ ELSE 9
658
+ END ASC,
659
+ CASE
660
+ WHEN status = 'done' THEN -COALESCE(processed_at, 0)
661
+ ELSE 0
662
+ END ASC,
663
+ CASE
664
+ WHEN status = 'pending' AND tt_seller = 1 AND verified = 0 THEN 0
665
+ ELSE 1
666
+ END ASC,
667
+ CASE
668
+ WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('PL', 'NL', 'BE', 'AT') THEN 0
669
+ WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('DE', 'FR', 'IT', 'IE', 'ES') THEN 1
670
+ ELSE 2
671
+ END ASC,
672
+ COALESCE(follower_count, 0) DESC,
673
+ COALESCE(processed_at, 0) DESC,
674
+ unique_id ASC
675
+ LIMIT ? OFFSET ?
676
+ `,
677
+ )
678
+ .all(...args, safeLimit, safeOffset)
679
+ .map(mapJobRow);
680
+
681
+ return {
682
+ total,
683
+ users: rows,
684
+ };
685
+ }
686
+
687
+ function getTargetUsersFromDb(targetLocations = []) {
688
+ if (!db) return null;
689
+ if (!targetLocations.length) {
690
+ return { total: 0, users: [] };
691
+ }
692
+
693
+ const placeholders = targetLocations.map(() => "?").join(", ");
694
+ const rows = db
695
+ .prepare(
696
+ `
697
+ SELECT
698
+ unique_id,
699
+ nickname,
700
+ follower_count,
701
+ tt_seller,
702
+ verified,
703
+ location_created,
704
+ status,
705
+ sources
706
+ FROM jobs
707
+ WHERE tt_seller = 1
708
+ AND verified = 0
709
+ AND location_created IN (${placeholders})
710
+ ORDER BY COALESCE(follower_count, 0) DESC, unique_id ASC
711
+ `,
712
+ )
713
+ .all(...targetLocations)
714
+ .map(mapJobRow);
715
+
716
+ return {
717
+ total: rows.length,
718
+ users: rows,
719
+ };
720
+ }
721
+
722
+ function snakeToCamel(key) {
723
+ return key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
724
+ }
725
+
726
+ function camelToSnake(key) {
727
+ return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
728
+ }
729
+
730
+ const jobBooleanColumns = new Set([
731
+ "pinned",
732
+ "no_video",
733
+ "restricted",
734
+ "processed",
735
+ "tt_seller",
736
+ "verified",
737
+ ]);
738
+
739
+ const videoBooleanColumns = new Set(["tt_seller"]);
740
+
741
+ const writableJobColumns = new Set([
742
+ "nickname",
743
+ "status",
744
+ "sources",
745
+ "claimed_by",
746
+ "claimed_at",
747
+ "error",
748
+ "pinned",
749
+ "no_video",
750
+ "restricted",
751
+ "user_update_count",
752
+ "tt_seller",
753
+ "verified",
754
+ "video_count",
755
+ "comment_count",
756
+ "guessed_location",
757
+ "location_created",
758
+ "follower_count",
759
+ "following_count",
760
+ "heart_count",
761
+ "refresh_time",
762
+ "processed",
763
+ "processed_at",
764
+ "updated_at",
765
+ "region",
766
+ "signature",
767
+ "sec_uid",
768
+ ]);
769
+
770
+ function normalizeJobValue(column, value) {
771
+ if (column === "sources") {
772
+ if (!Array.isArray(value)) return JSON.stringify([]);
773
+ return JSON.stringify([...new Set(value)]);
774
+ }
775
+ if (jobBooleanColumns.has(column)) {
776
+ if (value === undefined || value === null || value === "") return null;
777
+ return value ? 1 : 0;
778
+ }
779
+ return value;
780
+ }
781
+
782
+ function mapJobRow(row) {
783
+ if (!row) return undefined;
784
+ const mapped = {};
785
+ for (const [key, value] of Object.entries(row)) {
786
+ const camelKey = snakeToCamel(key);
787
+ if (key === "sources") {
788
+ try {
789
+ mapped[camelKey] = value ? JSON.parse(value) : [];
790
+ } catch {
791
+ mapped[camelKey] = [];
792
+ }
793
+ continue;
794
+ }
795
+ if (jobBooleanColumns.has(key)) {
796
+ mapped[camelKey] = value === null || value === undefined ? null : !!value;
797
+ continue;
798
+ }
799
+ mapped[camelKey] = value;
800
+ }
801
+ return mapped;
802
+ }
803
+
804
+ function getJobRow(uniqueId) {
805
+ if (!db) return null;
806
+ return db.prepare("SELECT * FROM jobs WHERE unique_id = ?").get(uniqueId);
807
+ }
808
+
809
+ function getJob(uniqueId) {
810
+ return mapJobRow(getJobRow(uniqueId));
811
+ }
812
+
813
+ function getAllJobs() {
814
+ if (!db) return [];
815
+ return db.prepare("SELECT * FROM jobs").all().map(mapJobRow);
816
+ }
817
+
818
+ function mapVideoRow(row) {
819
+ if (!row) return undefined;
820
+ const mapped = {};
821
+ for (const [key, value] of Object.entries(row)) {
822
+ const camelKey = snakeToCamel(key);
823
+ if (videoBooleanColumns.has(key)) {
824
+ mapped[camelKey] = value === null || value === undefined ? null : !!value;
825
+ continue;
826
+ }
827
+ mapped[camelKey] = value;
828
+ }
829
+ return mapped;
830
+ }
831
+
832
+ function getVideoRow(videoId) {
833
+ if (!db) return null;
834
+ return db.prepare("SELECT * FROM videos WHERE id = ?").get(videoId);
835
+ }
836
+
837
+ function getAllVideoRows() {
838
+ if (!db) return [];
839
+ return db.prepare("SELECT * FROM videos").all();
840
+ }
841
+
842
+ function updateJobInfo(uniqueId, info, incrementCount = true) {
843
+ if (!db) return { error: "db not initialized" };
844
+ const existing = getJobRow(uniqueId);
845
+ if (!existing) return { error: "user not found" };
846
+
847
+ const nextValues = {};
848
+ for (const [key, value] of Object.entries(info || {})) {
849
+ if (key === "uniqueId" || key === "unique_id") continue;
850
+ if (value === undefined || value === "") continue;
851
+ const column = camelToSnake(key);
852
+ if (!writableJobColumns.has(column)) continue;
853
+ nextValues[column] = normalizeJobValue(column, value);
854
+ }
855
+
856
+ nextValues.updated_at = Date.now();
857
+ if (incrementCount) {
858
+ nextValues.user_update_count = (existing.user_update_count || 0) + 1;
859
+ }
860
+
861
+ const columns = Object.keys(nextValues);
862
+ if (columns.length > 0) {
863
+ const sql = `UPDATE jobs SET ${columns.map((column) => `${column} = ?`).join(", ")} WHERE unique_id = ?`;
864
+ db.prepare(sql).run(
865
+ ...columns.map((column) => nextValues[column]),
866
+ uniqueId,
867
+ );
868
+ }
869
+
870
+ return {
871
+ ok: true,
872
+ userUpdateCount:
873
+ nextValues.user_update_count ?? existing.user_update_count ?? 0,
874
+ };
875
+ }
4
876
 
5
877
  function inferStatus(u) {
6
878
  if (u.restricted) return "restricted";
@@ -9,27 +881,32 @@ function inferStatus(u) {
9
881
  return "pending";
10
882
  }
11
883
 
884
+ function addJob(user) {
885
+ if (!db) {
886
+ addUserToDb(user);
887
+ return;
888
+ }
889
+ if (!user.status) user.status = inferStatus(user);
890
+ if (!user.createdAt) user.createdAt = Date.now();
891
+ if (!user.updatedAt) user.updatedAt = user.createdAt;
892
+ const writeTxn = db.transaction((job) => {
893
+ addUserToDb(job);
894
+ addJobToDb(job);
895
+ });
896
+ writeTxn(user);
897
+ }
898
+
12
899
  export function createStore(filePath) {
900
+ if (!filePath) {
901
+ throw new Error("createStore requires an explicit .db path");
902
+ }
13
903
  let data = [];
14
904
  // uniqueId → index 内存索引,O(1) 查找
15
905
  let uidIndex = new Map();
16
906
  let clientErrors = new Map();
17
-
18
- // done 用户归档(独立文件,主文件只保留活跃用户)
19
- let doneArchive = [];
20
- let doneArchivePath = null;
21
- let doneUidIndex = new Map();
22
907
  if (filePath) {
23
- doneArchivePath = path.resolve(filePath).replace(/\.json$/, "-done.json");
24
- if (fs.existsSync(doneArchivePath)) {
25
- try {
26
- const content = fs.readFileSync(doneArchivePath, "utf-8");
27
- const parsed = JSON.parse(content);
28
- if (Array.isArray(parsed)) doneArchive = parsed;
29
- } catch (e) {
30
- console.error(`[data-store] 读取 done 归档失败: ${e.message}`);
31
- }
32
- }
908
+ // 初始化 SQLite 用户表(用于判重)
909
+ initUserDb(filePath);
33
910
  }
34
911
 
35
912
  // stats 缓存
@@ -42,6 +919,34 @@ export function createStore(filePath) {
42
919
  }
43
920
 
44
921
  function computeStatsInternal() {
922
+ if (db) {
923
+ const total = getJobsCount();
924
+ const statusCounts = {
925
+ pending: 0,
926
+ processing: 0,
927
+ done: 0,
928
+ error: 0,
929
+ restricted: 0,
930
+ };
931
+ const rows = db
932
+ .prepare(
933
+ `
934
+ SELECT status, COUNT(*) as count
935
+ FROM jobs
936
+ GROUP BY status
937
+ `,
938
+ )
939
+ .all();
940
+ for (const row of rows) {
941
+ if (row.status && statusCounts[row.status] !== undefined) {
942
+ statusCounts[row.status] = row.count;
943
+ }
944
+ }
945
+ statsCache = { total, statusCounts };
946
+ statsDirty = false;
947
+ return statsCache;
948
+ }
949
+
45
950
  const total = data.length;
46
951
  const statusCounts = {
47
952
  pending: 0,
@@ -99,6 +1004,26 @@ export function createStore(filePath) {
99
1004
  }
100
1005
 
101
1006
  function rebuildStatusGroups() {
1007
+ if (db) {
1008
+ statusGroups = {
1009
+ pending: [],
1010
+ processing: [],
1011
+ done: [],
1012
+ error: [],
1013
+ restricted: [],
1014
+ };
1015
+ for (const u of getAllJobs()) {
1016
+ const key = u.status || "pending";
1017
+ if (statusGroups[key]) statusGroups[key].push(u);
1018
+ else statusGroups[key] = [u];
1019
+ }
1020
+ for (const key of Object.keys(statusGroups)) {
1021
+ statusGroups[key] = sortGroup(key, statusGroups[key]);
1022
+ }
1023
+ groupsDirty = false;
1024
+ return;
1025
+ }
1026
+
102
1027
  statusGroups = {
103
1028
  pending: [],
104
1029
  processing: [],
@@ -111,10 +1036,6 @@ export function createStore(filePath) {
111
1036
  if (statusGroups[key]) statusGroups[key].push(u);
112
1037
  else statusGroups[key] = [u];
113
1038
  }
114
- // done 归档单独处理
115
- if (doneArchive.length > 0) {
116
- statusGroups.done = statusGroups.done.concat(doneArchive);
117
- }
118
1039
  // 各组内排序
119
1040
  for (const key of Object.keys(statusGroups)) {
120
1041
  statusGroups[key] = sortGroup(key, statusGroups[key]);
@@ -131,69 +1052,8 @@ export function createStore(filePath) {
131
1052
  groupsDirty = true;
132
1053
  }
133
1054
 
134
- // 视频存储(独立 JSON 文件)
1055
+ // 视频存储(SQLite 真相源)
135
1056
  let videos = [];
136
- let videoFilePath = null;
137
- if (filePath) {
138
- const resolved = path.resolve(filePath);
139
- videoFilePath = resolved.replace(/\.json$/, "-videos.json");
140
- if (fs.existsSync(videoFilePath)) {
141
- try {
142
- const content = fs.readFileSync(videoFilePath, "utf-8");
143
- const parsed = JSON.parse(content);
144
- if (Array.isArray(parsed)) {
145
- videos = parsed;
146
- }
147
- } catch (e) {
148
- console.error(`[data-store] 读取视频文件失败: ${e.message}`);
149
- }
150
- }
151
- }
152
-
153
- let backupTimer = null;
154
-
155
- if (filePath) {
156
- const resolved = path.resolve(filePath);
157
- const backupDir = path.join(path.dirname(resolved), ".backup");
158
- const maxBackups = 3;
159
-
160
- if (fs.existsSync(resolved)) {
161
- try {
162
- const content = fs.readFileSync(resolved, "utf-8");
163
- data = JSON.parse(content);
164
- if (!Array.isArray(data)) {
165
- data = [];
166
- }
167
- } catch (e) {
168
- console.error(`[data-store] 读取文件失败: ${e.message}`);
169
- data = [];
170
- }
171
- }
172
-
173
- function runBackup() {
174
- if (!fs.existsSync(resolved)) return;
175
- if (!fs.existsSync(backupDir))
176
- fs.mkdirSync(backupDir, { recursive: true });
177
- const now = new Date();
178
- const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 13);
179
- const backupFile = path.join(backupDir, `data-${timestamp}.json`);
180
- try {
181
- fs.copyFileSync(resolved, backupFile);
182
- const files = fs
183
- .readdirSync(backupDir)
184
- .filter((f) => f.startsWith("data-") && f.endsWith(".json"))
185
- .sort()
186
- .map((f) => path.join(backupDir, f));
187
- while (files.length > maxBackups) {
188
- fs.unlinkSync(files.shift());
189
- }
190
- } catch (e) {
191
- console.error(`[data-store] 备份失败: ${e.message}`);
192
- }
193
- }
194
-
195
- backupTimer = setInterval(runBackup, 60 * 60 * 1000);
196
- }
197
1057
 
198
1058
  // 构建索引 + 推断 status
199
1059
  for (let i = 0; i < data.length; i++) {
@@ -202,189 +1062,69 @@ export function createStore(filePath) {
202
1062
  uidIndex.set(u.uniqueId, i);
203
1063
  }
204
1064
 
205
- // 构建 done 归档索引
206
- for (let i = 0; i < doneArchive.length; i++) {
207
- doneUidIndex.set(doneArchive[i].uniqueId, i);
208
- }
209
-
210
- function rebuildIndex() {
211
- uidIndex = new Map();
212
- for (let i = 0; i < data.length; i++) {
213
- uidIndex.set(data[i].uniqueId, i);
214
- }
215
- }
216
-
217
- let saveTimer = null;
218
- let savePending = false;
219
-
220
- function scheduleSave() {
221
- if (saveTimer) return;
222
- savePending = true;
223
- saveTimer = setTimeout(() => {
224
- saveTimer = null;
225
- savePending = false;
226
- doSave();
227
- }, 2000);
228
- saveTimer.unref();
229
- }
230
-
231
- function doSave() {
232
- if (!filePath) return;
233
- const resolved = path.resolve(filePath);
234
-
235
- // 将 done 用户归档到独立文件
236
- if (doneArchivePath && data.length > 1000) {
237
- const active = [];
238
- const toArchive = [];
239
- for (const u of data) {
240
- if (u.status === "done") {
241
- toArchive.push(u);
242
- } else {
243
- active.push(u);
244
- }
245
- }
246
- if (toArchive.length > 0) {
247
- doneArchive = doneArchive.concat(toArchive);
248
- data = active;
249
- rebuildIndex();
250
- // 重建 done 归档索引
251
- doneUidIndex = new Map();
252
- for (let i = 0; i < doneArchive.length; i++) {
253
- doneUidIndex.set(doneArchive[i].uniqueId, i);
254
- }
255
- }
256
- }
257
-
258
- const json = JSON.stringify(data);
259
- const tmpPath = resolved + ".tmp";
260
- fsPromises
261
- .writeFile(tmpPath, json, "utf-8")
262
- .then(() => fsPromises.rename(tmpPath, resolved))
263
- .then(() => {
264
- if (doneArchivePath && doneArchive.length > 0) {
265
- const doneJson = JSON.stringify(doneArchive);
266
- const doneTmp = doneArchivePath + ".tmp";
267
- return fsPromises
268
- .writeFile(doneTmp, doneJson, "utf-8")
269
- .then(() => fsPromises.rename(doneTmp, doneArchivePath));
270
- }
271
- })
272
- .catch((err) => {
273
- console.error(`[data-store] save 写入失败: ${err.message}`);
274
- });
275
- }
276
-
277
1065
  function save() {
278
- scheduleSave();
1066
+ return;
279
1067
  }
280
1068
 
281
1069
  function flushSave() {
282
- return new Promise((resolve) => {
283
- if (saveTimer) {
284
- clearTimeout(saveTimer);
285
- saveTimer = null;
286
- savePending = false;
287
- }
288
- if (!filePath) {
289
- resolve();
290
- return;
291
- }
292
- const resolved = path.resolve(filePath);
293
-
294
- // 将 done 用户归档到独立文件
295
- if (doneArchivePath && data.length > 1000) {
296
- const active = [];
297
- const toArchive = [];
298
- for (const u of data) {
299
- if (u.status === "done") {
300
- toArchive.push(u);
301
- } else {
302
- active.push(u);
303
- }
304
- }
305
- if (toArchive.length > 0) {
306
- doneArchive = doneArchive.concat(toArchive);
307
- data = active;
308
- rebuildIndex();
309
- doneUidIndex = new Map();
310
- for (let i = 0; i < doneArchive.length; i++) {
311
- doneUidIndex.set(doneArchive[i].uniqueId, i);
312
- }
313
- }
314
- }
315
-
316
- const json = JSON.stringify(data);
317
- const tmpPath = resolved + ".tmp";
318
- fsPromises
319
- .writeFile(tmpPath, json, "utf-8")
320
- .then(() => fsPromises.rename(tmpPath, resolved))
321
- .then(() => {
322
- if (doneArchivePath && doneArchive.length > 0) {
323
- const doneJson = JSON.stringify(doneArchive);
324
- const doneTmp = doneArchivePath + ".tmp";
325
- return fsPromises
326
- .writeFile(doneTmp, doneJson, "utf-8")
327
- .then(() => fsPromises.rename(doneTmp, doneArchivePath));
328
- }
329
- resolve();
330
- })
331
- .catch((err) => {
332
- console.error(`[data-store] flushSave 写入失败: ${err.message}`);
333
- resolve();
334
- });
335
- });
1070
+ return Promise.resolve();
336
1071
  }
337
1072
 
338
- let videosSaveTimer = null;
339
-
340
1073
  function saveVideos() {
341
- if (!videoFilePath) return;
342
- if (videosSaveTimer) return;
343
- videosSaveTimer = setTimeout(() => {
344
- videosSaveTimer = null;
345
- const json = JSON.stringify(videos, null, 2);
346
- fsPromises.writeFile(videoFilePath, json, "utf-8").catch((err) => {
347
- console.error(`[data-store] saveVideos 写入失败: ${err.message}`);
348
- });
349
- }, 2000);
350
- videosSaveTimer.unref();
1074
+ return;
351
1075
  }
352
1076
 
353
1077
  function stopBackup() {
354
- if (backupTimer) {
355
- clearInterval(backupTimer);
356
- backupTimer = null;
357
- }
1078
+ return;
358
1079
  }
359
1080
 
360
1081
  function getUser(uid) {
361
1082
  const idx = uidIndex.get(uid);
362
1083
  if (idx !== undefined) return data[idx];
363
- const doneIdx = doneUidIndex.get(uid);
364
- if (doneIdx !== undefined) return doneArchive[doneIdx];
1084
+ if (db) return getJob(uid);
365
1085
  return undefined;
366
1086
  }
367
1087
 
368
1088
  function hasUser(uid) {
369
- return uidIndex.has(uid) || doneUidIndex.has(uid);
1089
+ // 优先用内存索引,兜底用 SQLite
1090
+ if (uidIndex.has(uid)) return true;
1091
+ return hasUserInDb(uid);
370
1092
  }
371
1093
 
372
1094
  function userExists(uid) {
373
- return uidIndex.has(uid) || doneUidIndex.has(uid);
1095
+ // 优先用内存索引,兜底用 SQLite
1096
+ if (uidIndex.has(uid)) return true;
1097
+ return hasUserInDb(uid);
374
1098
  }
375
1099
 
376
1100
  function addUser(user, append) {
1101
+ const memoryIdx = uidIndex.get(user.uniqueId);
1102
+ if (db && memoryIdx === undefined) {
1103
+ const existingJob = getJobRow(user.uniqueId);
1104
+ if (existingJob) {
1105
+ return updateJobInfo(user.uniqueId, user, false);
1106
+ }
1107
+ addJob(user);
1108
+ return;
1109
+ }
1110
+
377
1111
  const existing = getUser(user.uniqueId);
378
1112
  if (existing) {
1113
+ let changed = false;
379
1114
  for (const key of Object.keys(user)) {
380
1115
  if (key === "uniqueId" || key === "sources") continue;
381
1116
  if (user[key] !== undefined && user[key] !== null && user[key] !== "") {
382
- existing[key] = user[key];
1117
+ if (existing[key] !== user[key]) {
1118
+ existing[key] = user[key];
1119
+ changed = true;
1120
+ }
383
1121
  }
384
1122
  }
1123
+ if (changed) save();
385
1124
  } else {
386
1125
  if (!user.status) user.status = inferStatus(user);
387
1126
  if (user.processed) user.processedAt = user.processedAt || Date.now();
1127
+ if (!user.createdAt) user.createdAt = Date.now();
388
1128
  if (append) {
389
1129
  const idx = data.length;
390
1130
  data.push(user);
@@ -393,23 +1133,30 @@ export function createStore(filePath) {
393
1133
  data.unshift(user);
394
1134
  uidIndex.set(user.uniqueId, 0);
395
1135
  }
1136
+ // 同步写入 SQLite
1137
+ addUserToDb(user);
396
1138
  markStatsDirty();
1139
+ save();
397
1140
  }
398
1141
  }
399
1142
 
400
1143
  function getPendingUsers() {
1144
+ if (db) {
1145
+ return getAllJobs().filter((u) => u.status === "pending");
1146
+ }
401
1147
  return data.filter((u) => u.status === "pending");
402
1148
  }
403
1149
 
404
1150
  function getProcessedUsers() {
405
- return doneArchive.length > 0
406
- ? data.filter((u) => u.status === "done").concat(doneArchive)
407
- : data.filter((u) => u.status === "done");
1151
+ if (db) {
1152
+ return getAllJobs().filter((u) => u.status === "done");
1153
+ }
1154
+ return data.filter((u) => u.status === "done");
408
1155
  }
409
1156
 
410
1157
  function getAllUsers() {
411
- if (doneArchive.length > 0) {
412
- return data.concat(doneArchive);
1158
+ if (db) {
1159
+ return getAllJobs();
413
1160
  }
414
1161
  return data;
415
1162
  }
@@ -420,6 +1167,269 @@ export function createStore(filePath) {
420
1167
  locations = null,
421
1168
  loggedIn = true,
422
1169
  ) {
1170
+ if (db) {
1171
+ const now = Date.now();
1172
+ const ongoingRow = db
1173
+ .prepare(
1174
+ `
1175
+ SELECT *
1176
+ FROM jobs
1177
+ WHERE status = 'processing'
1178
+ AND claimed_by = ?
1179
+ AND claimed_at IS NOT NULL
1180
+ AND ? - claimed_at < ?
1181
+ ORDER BY claimed_at DESC
1182
+ LIMIT 1
1183
+ `,
1184
+ )
1185
+ .get(userId, now, expireMs);
1186
+ if (ongoingRow) {
1187
+ db.prepare("UPDATE jobs SET claimed_at = ? WHERE unique_id = ?").run(
1188
+ now,
1189
+ ongoingRow.unique_id,
1190
+ );
1191
+ return {
1192
+ uniqueId: ongoingRow.unique_id,
1193
+ nickname: ongoingRow.nickname,
1194
+ claimedAt: now,
1195
+ claimedBy: userId,
1196
+ };
1197
+ }
1198
+
1199
+ const tier1 = new Set(["PL", "NL", "BE"]);
1200
+ const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1201
+ const normalizedLocations = Array.isArray(locations)
1202
+ ? locations
1203
+ .map((loc) => String(loc).trim().toUpperCase())
1204
+ .filter(Boolean)
1205
+ : [];
1206
+
1207
+ function getLocationGroups() {
1208
+ const selected = normalizedLocations.length
1209
+ ? normalizedLocations
1210
+ : null;
1211
+ const tier1List = selected
1212
+ ? selected.filter((loc) => tier1.has(loc))
1213
+ : [...tier1];
1214
+ const tier2List = selected
1215
+ ? selected.filter((loc) => tier2.has(loc))
1216
+ : [...tier2];
1217
+ const otherList = selected
1218
+ ? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
1219
+ : null;
1220
+ const groups = [];
1221
+ if (tier1List.length > 0)
1222
+ groups.push({ type: "include", values: tier1List });
1223
+ if (tier2List.length > 0)
1224
+ groups.push({ type: "include", values: tier2List });
1225
+ if (selected) {
1226
+ if (otherList.length > 0)
1227
+ groups.push({ type: "include", values: otherList });
1228
+ } else {
1229
+ groups.push({ type: "exclude", values: [...tier1, ...tier2] });
1230
+ }
1231
+ return groups;
1232
+ }
1233
+
1234
+ const locationGroups = getLocationGroups();
1235
+
1236
+ function applyLocationGroup(where, args, group) {
1237
+ if (!group) return;
1238
+ if (group.type === "include") {
1239
+ where.push(
1240
+ `UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")})`,
1241
+ );
1242
+ args.push(...group.values);
1243
+ return;
1244
+ }
1245
+ where.push(
1246
+ `UPPER(COALESCE(guessed_location, '')) NOT IN (${group.values.map(() => "?").join(", ")})`,
1247
+ );
1248
+ args.push(...group.values);
1249
+ }
1250
+
1251
+ function queryPendingOne({ requireVideo, group, filters = [] }) {
1252
+ const where = ["status = 'pending'"];
1253
+ const args = [];
1254
+ if (!loggedIn) {
1255
+ where.push("COALESCE(tt_seller, 0) != 1");
1256
+ }
1257
+ if (requireVideo) {
1258
+ where.push("COALESCE(video_count, 0) > 0");
1259
+ }
1260
+ applyLocationGroup(where, args, group);
1261
+ for (const filter of filters) {
1262
+ where.push(filter);
1263
+ }
1264
+ return db
1265
+ .prepare(
1266
+ `
1267
+ SELECT *
1268
+ FROM jobs
1269
+ WHERE ${where.join(" AND ")}
1270
+ ORDER BY follower_count DESC, created_at ASC, unique_id ASC
1271
+ LIMIT 1
1272
+ `,
1273
+ )
1274
+ .get(...args);
1275
+ }
1276
+
1277
+ function queryPendingByGroup({ requireVideo, group, filters = [] }) {
1278
+ if (group?.type === "include" && group.values.length > 1) {
1279
+ for (const location of group.values) {
1280
+ const row = queryPendingOne({
1281
+ requireVideo,
1282
+ group: { type: "include", values: [location] },
1283
+ filters,
1284
+ });
1285
+ if (row) return row;
1286
+ }
1287
+ return null;
1288
+ }
1289
+ return queryPendingOne({ requireVideo, group, filters });
1290
+ }
1291
+
1292
+ function findPinnedPending(requireVideo) {
1293
+ const where = ["status = 'pending'", "COALESCE(pinned, 0) = 1"];
1294
+ const args = [];
1295
+ if (!loggedIn) {
1296
+ where.push("COALESCE(tt_seller, 0) != 1");
1297
+ }
1298
+ if (requireVideo) {
1299
+ where.push("COALESCE(video_count, 0) > 0");
1300
+ }
1301
+ if (normalizedLocations.length > 0) {
1302
+ where.push(
1303
+ `UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
1304
+ );
1305
+ args.push(...normalizedLocations);
1306
+ }
1307
+ return db
1308
+ .prepare(
1309
+ `
1310
+ SELECT *
1311
+ FROM jobs
1312
+ WHERE ${where.join(" AND ")}
1313
+ ORDER BY created_at ASC, unique_id ASC
1314
+ LIMIT 1
1315
+ `,
1316
+ )
1317
+ .get(...args);
1318
+ }
1319
+
1320
+ function findPrioritizedPending(requireVideo) {
1321
+ for (const group of locationGroups) {
1322
+ const seed = queryPendingByGroup({
1323
+ requireVideo,
1324
+ group,
1325
+ filters: [
1326
+ "COALESCE(pinned, 0) = 0",
1327
+ `instr(COALESCE(sources, ''), '"seed"') > 0`,
1328
+ ],
1329
+ });
1330
+ if (seed) return seed;
1331
+ }
1332
+
1333
+ if (loggedIn) {
1334
+ for (const group of locationGroups) {
1335
+ const seller = queryPendingByGroup({
1336
+ requireVideo,
1337
+ group,
1338
+ filters: [
1339
+ "COALESCE(pinned, 0) = 0",
1340
+ "tt_seller = 1",
1341
+ "verified = 0",
1342
+ ],
1343
+ });
1344
+ if (seller) return seller;
1345
+ }
1346
+ }
1347
+
1348
+ for (const group of locationGroups) {
1349
+ const follow = queryPendingByGroup({
1350
+ requireVideo,
1351
+ group,
1352
+ filters: [
1353
+ "COALESCE(pinned, 0) = 0",
1354
+ `(
1355
+ instr(COALESCE(sources, ''), '"following"') > 0
1356
+ OR instr(COALESCE(sources, ''), '"follower"') > 0
1357
+ )`,
1358
+ ],
1359
+ });
1360
+ if (follow) return follow;
1361
+ }
1362
+
1363
+ for (const group of locationGroups) {
1364
+ const other = queryPendingByGroup({
1365
+ requireVideo,
1366
+ group,
1367
+ filters: ["COALESCE(pinned, 0) = 0"],
1368
+ });
1369
+ if (other) return other;
1370
+ }
1371
+
1372
+ return null;
1373
+ }
1374
+
1375
+ function claimRow(row) {
1376
+ if (!row) return null;
1377
+ db.prepare(
1378
+ "UPDATE jobs SET status = 'processing', claimed_at = ?, claimed_by = ? WHERE unique_id = ?",
1379
+ ).run(now, userId, row.unique_id);
1380
+ markStatsDirty();
1381
+ return {
1382
+ uniqueId: row.unique_id,
1383
+ nickname: row.nickname,
1384
+ claimedAt: now,
1385
+ claimedBy: userId,
1386
+ };
1387
+ }
1388
+
1389
+ const expiredRow = db
1390
+ .prepare(
1391
+ `
1392
+ SELECT *
1393
+ FROM jobs
1394
+ WHERE status = 'processing'
1395
+ AND claimed_at IS NOT NULL
1396
+ AND ? - claimed_at > ?
1397
+ ORDER BY claimed_at ASC
1398
+ LIMIT 1
1399
+ `,
1400
+ )
1401
+ .get(now, expireMs);
1402
+ let expiredCandidate = null;
1403
+ if (expiredRow) {
1404
+ db.prepare(
1405
+ "UPDATE jobs SET status = 'pending', claimed_at = NULL WHERE unique_id = ?",
1406
+ ).run(expiredRow.unique_id);
1407
+ expiredCandidate = mapJobRow({
1408
+ ...expiredRow,
1409
+ status: "pending",
1410
+ claimed_at: null,
1411
+ });
1412
+ }
1413
+
1414
+ for (const requireVideo of [true, false]) {
1415
+ const pinned = findPinnedPending(requireVideo);
1416
+ if (pinned) {
1417
+ return claimRow(pinned);
1418
+ }
1419
+ if (expiredCandidate) {
1420
+ return claimRow({
1421
+ unique_id: expiredCandidate.uniqueId,
1422
+ nickname: expiredCandidate.nickname,
1423
+ });
1424
+ }
1425
+ const ranked = findPrioritizedPending(requireVideo);
1426
+ if (ranked) {
1427
+ return claimRow(ranked);
1428
+ }
1429
+ }
1430
+ return null;
1431
+ }
1432
+
423
1433
  const now = Date.now();
424
1434
 
425
1435
  // 0. 该客户端有未过期的任务,续期返回
@@ -432,6 +1442,7 @@ export function createStore(filePath) {
432
1442
  );
433
1443
  if (ongoing) {
434
1444
  ongoing.claimedAt = now;
1445
+ save();
435
1446
  return {
436
1447
  uniqueId: ongoing.uniqueId,
437
1448
  nickname: ongoing.nickname,
@@ -529,6 +1540,7 @@ export function createStore(filePath) {
529
1540
  markStatsDirty();
530
1541
  next.claimedAt = now;
531
1542
  next.claimedBy = userId;
1543
+ save();
532
1544
  return {
533
1545
  uniqueId: next.uniqueId,
534
1546
  nickname: next.nickname,
@@ -621,7 +1633,7 @@ export function createStore(filePath) {
621
1633
  const newUsers = [];
622
1634
  for (const d of unique) {
623
1635
  if (!hasUser(d.uniqueId)) {
624
- addUser(d, true);
1636
+ addJob(d);
625
1637
  newUsers.push(d.uniqueId);
626
1638
  }
627
1639
  }
@@ -715,6 +1727,20 @@ export function createStore(filePath) {
715
1727
  }
716
1728
 
717
1729
  function commitJob(uniqueId, result) {
1730
+ if (db) {
1731
+ const user = getJob(uniqueId);
1732
+ if (!user) return { saved: false, error: "user not found" };
1733
+
1734
+ updateUserFromResult(user, result);
1735
+ user.claimedAt = null;
1736
+ const newUsers = processDiscoveredUsers(result);
1737
+ const persistRet = updateJobInfo(uniqueId, user, false);
1738
+ if (persistRet.error) {
1739
+ return { saved: false, error: persistRet.error };
1740
+ }
1741
+ return { saved: true, status: user.status, newUsers };
1742
+ }
1743
+
718
1744
  const user = getUser(uniqueId);
719
1745
  if (!user) return { saved: false, error: "user not found" };
720
1746
 
@@ -727,6 +1753,34 @@ export function createStore(filePath) {
727
1753
  }
728
1754
 
729
1755
  function commitNewExplore(uniqueId, result) {
1756
+ if (db) {
1757
+ const existing = getJob(uniqueId);
1758
+ if (existing) {
1759
+ updateUserFromResult(existing, result);
1760
+ const persistRet = updateJobInfo(uniqueId, existing, false);
1761
+ if (persistRet.error) {
1762
+ return { saved: false, error: persistRet.error };
1763
+ }
1764
+ const newUsers = processDiscoveredUsers(result);
1765
+ return {
1766
+ saved: true,
1767
+ created: false,
1768
+ status: existing.status,
1769
+ newUsers,
1770
+ };
1771
+ }
1772
+
1773
+ const userObj = {
1774
+ uniqueId,
1775
+ ...(result.userInfo || {}),
1776
+ sources: ["refresh-explore"],
1777
+ };
1778
+ updateUserFromResult(userObj, result);
1779
+ addJob(userObj);
1780
+ const newUsers = processDiscoveredUsers(result);
1781
+ return { saved: true, created: true, status: userObj.status, newUsers };
1782
+ }
1783
+
730
1784
  const existing = getUser(uniqueId);
731
1785
  if (existing) {
732
1786
  updateUserFromResult(existing, result);
@@ -749,6 +1803,22 @@ export function createStore(filePath) {
749
1803
  }
750
1804
 
751
1805
  function resetJob(uniqueId) {
1806
+ if (db) {
1807
+ const user = getJob(uniqueId);
1808
+ if (!user) return { saved: false, error: "user not found" };
1809
+ user.status = "pending";
1810
+ user.claimedAt = null;
1811
+ user.processedAt = null;
1812
+ user.processed = false;
1813
+ user.error = null;
1814
+ user.restricted = false;
1815
+ user.noVideo = false;
1816
+ const ret = updateJobInfo(uniqueId, user, false);
1817
+ if (ret.error) return { saved: false, error: ret.error };
1818
+ markStatsDirty();
1819
+ return { saved: true };
1820
+ }
1821
+
752
1822
  const user = getUser(uniqueId);
753
1823
  if (!user) return { saved: false, error: "user not found" };
754
1824
  user.status = "pending";
@@ -764,6 +1834,15 @@ export function createStore(filePath) {
764
1834
  }
765
1835
 
766
1836
  function togglePin(uniqueId) {
1837
+ if (db) {
1838
+ const user = getJob(uniqueId);
1839
+ if (!user) return { saved: false, error: "user not found" };
1840
+ const nextPinned = !user.pinned;
1841
+ const ret = updateJobInfo(uniqueId, { pinned: nextPinned }, false);
1842
+ if (ret.error) return { saved: false, error: ret.error };
1843
+ return { saved: true, pinned: nextPinned };
1844
+ }
1845
+
767
1846
  const user = getUser(uniqueId);
768
1847
  if (!user) return { saved: false, error: "user not found" };
769
1848
  user.pinned = !user.pinned;
@@ -772,6 +1851,35 @@ export function createStore(filePath) {
772
1851
  }
773
1852
 
774
1853
  function getNextRedoJob(userId) {
1854
+ if (db) {
1855
+ const now = Date.now();
1856
+ const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
1857
+ const targetLocations = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE"];
1858
+ const placeholders = targetLocations.map(() => "?").join(",");
1859
+ const row = db
1860
+ .prepare(
1861
+ `
1862
+ SELECT *
1863
+ FROM jobs
1864
+ WHERE tt_seller = 1
1865
+ AND verified = 0
1866
+ AND location_created IN (${placeholders})
1867
+ ORDER BY COALESCE(refresh_time, ?) ASC
1868
+ LIMIT 1
1869
+ `,
1870
+ )
1871
+ .get(...targetLocations, defaultTime);
1872
+ if (!row) return null;
1873
+ db.prepare(
1874
+ "UPDATE jobs SET refresh_time = ?, updated_at = ? WHERE unique_id = ?",
1875
+ ).run(now, now, row.unique_id);
1876
+ return {
1877
+ uniqueId: row.unique_id,
1878
+ nickname: row.nickname,
1879
+ refreshTime: now,
1880
+ };
1881
+ }
1882
+
775
1883
  const now = Date.now();
776
1884
  const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
777
1885
 
@@ -793,6 +1901,7 @@ export function createStore(filePath) {
793
1901
 
794
1902
  const next = targetUsers[0];
795
1903
  next.refreshTime = now;
1904
+ save();
796
1905
  return {
797
1906
  uniqueId: next.uniqueId,
798
1907
  nickname: next.nickname,
@@ -801,6 +1910,28 @@ export function createStore(filePath) {
801
1910
  }
802
1911
 
803
1912
  function commitRedoJob(uniqueId, result) {
1913
+ if (db) {
1914
+ const user = getJob(uniqueId);
1915
+ if (!user) return { saved: false, error: "user not found" };
1916
+ user.refreshTime = Date.now();
1917
+ if (result.userInfo) {
1918
+ const info = result.userInfo;
1919
+ for (const key of Object.keys(info)) {
1920
+ if (key === "uniqueId" || key === "sources") continue;
1921
+ if (
1922
+ info[key] !== undefined &&
1923
+ info[key] !== null &&
1924
+ info[key] !== ""
1925
+ ) {
1926
+ user[key] = info[key];
1927
+ }
1928
+ }
1929
+ }
1930
+ const ret = updateJobInfo(uniqueId, user, false);
1931
+ if (ret.error) return { saved: false, error: ret.error };
1932
+ return { saved: true };
1933
+ }
1934
+
804
1935
  const user = getUser(uniqueId);
805
1936
  if (!user) return { saved: false, error: "user not found" };
806
1937
 
@@ -871,6 +2002,44 @@ export function createStore(filePath) {
871
2002
  }
872
2003
 
873
2004
  function getPendingUserUpdateTasks(limit) {
2005
+ if (db) {
2006
+ const l = Math.max(1, parseInt(limit) || 5);
2007
+ const rows = db
2008
+ .prepare(
2009
+ `
2010
+ SELECT *
2011
+ FROM jobs
2012
+ WHERE COALESCE(tt_seller, '') = ''
2013
+ AND COALESCE(user_update_count, 0) <= 0
2014
+ ORDER BY created_at ASC, unique_id ASC
2015
+ LIMIT ?
2016
+ `,
2017
+ )
2018
+ .all(l);
2019
+ if (rows.length === 0) return [];
2020
+ const now = Date.now();
2021
+ const bumpStmt = db.prepare(
2022
+ `
2023
+ UPDATE jobs
2024
+ SET user_update_count = COALESCE(user_update_count, 0) + 1,
2025
+ updated_at = ?
2026
+ WHERE unique_id = ?
2027
+ `,
2028
+ );
2029
+ const bumpTxn = db.transaction((items) => {
2030
+ for (const item of items) {
2031
+ bumpStmt.run(now, item.unique_id);
2032
+ }
2033
+ });
2034
+ bumpTxn(rows);
2035
+ return rows.map((row) => {
2036
+ const mapped = mapJobRow(row);
2037
+ mapped.userUpdateCount = (mapped.userUpdateCount || 0) + 1;
2038
+ mapped.updatedAt = now;
2039
+ return mapped;
2040
+ });
2041
+ }
2042
+
874
2043
  const l = Math.max(1, parseInt(limit) || 5);
875
2044
  const pending = data
876
2045
  .filter((u) => {
@@ -893,6 +2062,10 @@ export function createStore(filePath) {
893
2062
  }
894
2063
 
895
2064
  function updateUserInfo(uniqueId, info) {
2065
+ if (db) {
2066
+ return updateJobInfo(uniqueId, info, true);
2067
+ }
2068
+
896
2069
  const user = getUser(uniqueId);
897
2070
  if (!user) return { error: "user not found" };
898
2071
  for (const key of Object.keys(info)) {
@@ -908,6 +2081,21 @@ export function createStore(filePath) {
908
2081
  }
909
2082
 
910
2083
  function batchUpdateUserInfo(updates) {
2084
+ if (db) {
2085
+ const txn = db.transaction((items) =>
2086
+ items.map((item) => updateJobInfo(item.uniqueId, item.info, true)),
2087
+ );
2088
+ return txn(updates).map((result, index) =>
2089
+ result.error
2090
+ ? { uniqueId: updates[index].uniqueId, error: result.error }
2091
+ : {
2092
+ uniqueId: updates[index].uniqueId,
2093
+ ok: true,
2094
+ userUpdateCount: result.userUpdateCount,
2095
+ },
2096
+ );
2097
+ }
2098
+
911
2099
  const results = [];
912
2100
  for (const item of updates) {
913
2101
  const user = getUser(item.uniqueId);
@@ -943,6 +2131,41 @@ export function createStore(filePath) {
943
2131
  return { registered: 0, skipped: 0 };
944
2132
  }
945
2133
 
2134
+ if (db) {
2135
+ const insertStmt = db.prepare(`
2136
+ INSERT OR IGNORE INTO videos (
2137
+ id,
2138
+ href,
2139
+ author_unique_id,
2140
+ location_created,
2141
+ tt_seller,
2142
+ registered_at,
2143
+ user_update_count
2144
+ )
2145
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2146
+ `);
2147
+ let registered = 0;
2148
+ let skipped = 0;
2149
+ const now = Date.now();
2150
+ const txn = db.transaction((items) => {
2151
+ for (const item of items) {
2152
+ const result = insertStmt.run(
2153
+ item.id,
2154
+ item.href || null,
2155
+ sourceUser,
2156
+ locationCreated || null,
2157
+ ttSeller ? 1 : 0,
2158
+ now,
2159
+ 0,
2160
+ );
2161
+ if (result.changes > 0) registered++;
2162
+ else skipped++;
2163
+ }
2164
+ });
2165
+ txn(videoList.filter((item) => item?.id));
2166
+ return { registered, skipped };
2167
+ }
2168
+
946
2169
  const existingIds = new Set(videos.map((v) => v.id));
947
2170
  let registered = 0;
948
2171
  let skipped = 0;
@@ -969,14 +2192,92 @@ export function createStore(filePath) {
969
2192
  }
970
2193
 
971
2194
  function getVideos() {
2195
+ if (db) {
2196
+ return getAllVideoRows().map(mapVideoRow);
2197
+ }
972
2198
  return videos;
973
2199
  }
974
2200
 
2201
+ function getVideo(videoId) {
2202
+ if (!videoId) return null;
2203
+ if (db) {
2204
+ return mapVideoRow(getVideoRow(videoId));
2205
+ }
2206
+ return videos.find((video) => video.id === videoId) || null;
2207
+ }
2208
+
2209
+ function getVideosPage(limit, offset) {
2210
+ const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
2211
+ const safeOffset = Math.max(0, parseInt(offset) || 0);
2212
+
2213
+ if (db) {
2214
+ const rows = db
2215
+ .prepare(
2216
+ `
2217
+ SELECT *
2218
+ FROM videos
2219
+ ORDER BY registered_at DESC, id DESC
2220
+ LIMIT ? OFFSET ?
2221
+ `,
2222
+ )
2223
+ .all(safeLimit, safeOffset);
2224
+ const total = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
2225
+ return {
2226
+ total,
2227
+ limit: safeLimit,
2228
+ offset: safeOffset,
2229
+ videos: rows.map(mapVideoRow),
2230
+ };
2231
+ }
2232
+
2233
+ return {
2234
+ total: videos.length,
2235
+ limit: safeLimit,
2236
+ offset: safeOffset,
2237
+ videos: videos.slice(safeOffset, safeOffset + safeLimit),
2238
+ };
2239
+ }
2240
+
975
2241
  function getVideoCount() {
2242
+ if (db) {
2243
+ return db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
2244
+ }
976
2245
  return videos.length;
977
2246
  }
978
2247
 
979
2248
  function getPendingCommentTasks(limit) {
2249
+ if (db) {
2250
+ const l = Math.max(1, parseInt(limit) || 1);
2251
+ const rows = db
2252
+ .prepare(
2253
+ `
2254
+ SELECT *
2255
+ FROM videos
2256
+ WHERE user_update_count IS NULL OR user_update_count <= 0
2257
+ ORDER BY tt_seller DESC, registered_at ASC
2258
+ LIMIT ?
2259
+ `,
2260
+ )
2261
+ .all(l);
2262
+ if (rows.length === 0) return [];
2263
+ const bumpStmt = db.prepare(
2264
+ `
2265
+ UPDATE videos
2266
+ SET user_update_count = COALESCE(user_update_count, 0) + 1
2267
+ WHERE id = ?
2268
+ `,
2269
+ );
2270
+ const bumpTxn = db.transaction((items) => {
2271
+ for (const item of items) bumpStmt.run(item.id);
2272
+ });
2273
+ bumpTxn(rows);
2274
+ return rows.map((row) => {
2275
+ const mapped = mapVideoRow(row);
2276
+ mapped.userUpdateCount = (mapped.userUpdateCount || 0) + 1;
2277
+ return mapped;
2278
+ });
2279
+ }
2280
+
980
2281
  // 筛选待处理视频(userUpdateCount <= 0 或 null/undefined)
981
2282
  const pending = videos.filter((v) => (v.userUpdateCount || 0) <= 0);
982
2283
  // ttSeller=true 优先
@@ -996,6 +2297,20 @@ export function createStore(filePath) {
996
2297
  }
997
2298
 
998
2299
  function commitCommentTask(videoId) {
2300
+ if (db) {
2301
+ const video = getVideoRow(videoId);
2302
+ if (!video) return { ok: false, error: "video not found" };
2303
+ const nextCount = (video.user_update_count || 0) + 1;
2304
+ db.prepare(
2305
+ `
2306
+ UPDATE videos
2307
+ SET user_update_count = ?
2308
+ WHERE id = ?
2309
+ `,
2310
+ ).run(nextCount, videoId);
2311
+ return { ok: true, userUpdateCount: nextCount };
2312
+ }
2313
+
999
2314
  const video = videos.find((v) => v.id === videoId);
1000
2315
  if (!video) return { ok: false, error: "video not found" };
1001
2316
  video.userUpdateCount = (video.userUpdateCount || 0) + 1;
@@ -1013,6 +2328,13 @@ export function createStore(filePath) {
1013
2328
  getPendingUsers,
1014
2329
  getProcessedUsers,
1015
2330
  getAllUsers,
2331
+ getUserDbCount,
2332
+ getJobsCount,
2333
+ getPendingJobsCount,
2334
+ getPendingJobsUserUpdateCount,
2335
+ getDashboardStats: getDashboardStatsFromDb,
2336
+ getUsersPage: getUsersPageFromDb,
2337
+ getTargetUsers: getTargetUsersFromDb,
1016
2338
  getStats,
1017
2339
  getStatusGroups,
1018
2340
  markGroupsDirty,
@@ -1030,7 +2352,9 @@ export function createStore(filePath) {
1030
2352
  deleteClientError,
1031
2353
  getClientErrors,
1032
2354
  registerVideos,
2355
+ getVideo,
1033
2356
  getVideos,
2357
+ getVideosPage,
1034
2358
  getVideoCount,
1035
2359
  getPendingCommentTasks,
1036
2360
  commitCommentTask,