@serifpersia/dango 3.3.8 → 3.4.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.
Files changed (180) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-D4qo4289.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BdRRm4So.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-DeJNAKMR.js → AnimeInfoPage-BxbA_7AV.js} +2 -2
  5. package/client/dist/assets/Asmr--Zc8G9zv.js +2 -0
  6. package/client/dist/assets/AsmrResetProgressModal-CKYm-8cp.js +1 -0
  7. package/client/dist/assets/AvSyncCalibrator-3yjZS7s6.js +10 -0
  8. package/client/dist/assets/Home-rrhOGQcg.js +1 -0
  9. package/client/dist/assets/Insights-Bay90Yeg.js +1 -0
  10. package/client/dist/assets/{LibraryListPage-D3hTqTRU.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-BgWsfu40.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Dn-CYins.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-EmQoK7Bv.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-Dian37PQ.js → MangaResetProgressModal-QjZyIAjl.js} +1 -1
  16. package/client/dist/assets/Mature-CHt_-Hz5.js +1 -0
  17. package/client/dist/assets/MediaCard-B5584Cq9.js +1 -0
  18. package/client/dist/assets/Music-HeLrEZ2d.js +1 -0
  19. package/client/dist/assets/OptionListMenu-CJ90CdwI.js +1 -0
  20. package/client/dist/assets/Player-BYtEax_z.js +56 -0
  21. package/client/dist/assets/PlayerSettings-4-cUAY4B.js +1 -0
  22. package/client/dist/assets/QueueOptionsButton-Ba0dg_82.js +1 -0
  23. package/client/dist/assets/{QueueRail-B7DeSFoA.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BT8kUjCB.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DWeiO-hs.js → ResetProgressModal-CcStlbhP.js} +1 -1
  27. package/client/dist/assets/Search-C2wrWOdy.js +1 -0
  28. package/client/dist/assets/Settings-CluUYs8U.js +21 -0
  29. package/client/dist/assets/{SkeletonGrid-B5loz7ii.js → SkeletonGrid-CLEPY9nP.js} +1 -1
  30. package/client/dist/assets/Trackers-CTGPxs0V.js +2 -0
  31. package/client/dist/assets/Tv-BwHlDdi8.js +4 -0
  32. package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
  33. package/client/dist/assets/TvInfo-CXnrLekr.js +1 -0
  34. package/client/dist/assets/TvPopup-BekfiNcX.js +1 -0
  35. package/client/dist/assets/{TvSearch-BYwCf5kO.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-BiJU6pKT.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-C11FOWHj.js → hls-Boje3IOE.js} +1 -1
  39. package/client/dist/assets/index-BXwhFWB_.js +178 -0
  40. package/client/dist/assets/useAnimeInfoData-DJ7A4B4T.js +1 -0
  41. package/client/dist/assets/useAsmr-DIwIsJXF.js +1 -0
  42. package/client/dist/assets/{useAsmrPopup-cT_Uu41e.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-PnmSctHu.js → useMangaPopup-B8nnpvyi.js} +1 -1
  45. package/client/dist/assets/useProviders-B5Qr4I1y.js +1 -0
  46. package/client/dist/assets/useTv-Dm8MCYlL.js +1 -0
  47. package/client/dist/assets/useTvLibrary-B7jEk2Q7.js +1 -0
  48. package/client/dist/assets/{vendor-query-BXRECSI6.js → vendor-query-BUTye89G.js} +1 -1
  49. package/client/dist/index.html +2 -2
  50. package/orchestrator.js +1 -2
  51. package/package.json +2 -6
  52. package/server/dist/app-auth.js +9 -29
  53. package/server/dist/app-hono.js +621 -0
  54. package/server/dist/config.js +0 -1
  55. package/server/dist/db/drizzle.js +32 -0
  56. package/server/dist/db/schema-anime.js +64 -0
  57. package/server/dist/db/schema-asmr.js +25 -0
  58. package/server/dist/db/schema-manga.js +31 -0
  59. package/server/dist/db/schema-tv.js +33 -0
  60. package/server/dist/db.js +75 -4
  61. package/server/dist/discord-gateway.js +1 -1
  62. package/server/dist/google.js +0 -7
  63. package/server/dist/hono/asmr.js +1 -0
  64. package/server/dist/hono/auth.js +56 -0
  65. package/server/dist/hono/music.js +419 -0
  66. package/server/dist/{controllers/proxy.controller.js → hono/proxy.js} +390 -394
  67. package/server/dist/hono/tv.js +197 -0
  68. package/server/dist/hono/watchlist.js +733 -0
  69. package/server/dist/lib/anilist.js +3 -3
  70. package/server/dist/lib/insights.js +185 -0
  71. package/server/dist/lib/ipc.js +1 -2
  72. package/server/dist/lib/kitsu.js +1 -28
  73. package/server/dist/lib/mal-import.js +291 -0
  74. package/server/dist/lib/migration.js +20 -34
  75. package/server/dist/lib/offline-db.js +36 -36
  76. package/server/dist/lib/tracker/manga-sync.service.js +21 -21
  77. package/server/dist/lib/tracker/sync.service.js +20 -20
  78. package/server/dist/lib/translate.js +79 -0
  79. package/server/dist/lib/ytmusic.js +15 -4
  80. package/server/dist/providers/title-matching.js +0 -1
  81. package/server/dist/repositories/asmr.repository.js +90 -87
  82. package/server/dist/repositories/insights.repository.js +24 -13
  83. package/server/dist/repositories/library.repository.js +33 -93
  84. package/server/dist/repositories/manga.repository.js +108 -116
  85. package/server/dist/repositories/notifications.repository.js +50 -31
  86. package/server/dist/repositories/queue.repository.js +36 -25
  87. package/server/dist/repositories/settings.repository.js +18 -8
  88. package/server/dist/repositories/shows-meta.repository.js +54 -41
  89. package/server/dist/repositories/temp-show-ids.repository.js +43 -31
  90. package/server/dist/repositories/tv.repository.js +98 -115
  91. package/server/dist/repositories/watched-episodes.repository.js +52 -20
  92. package/server/dist/repositories/watchlist.repository.js +36 -48
  93. package/server/dist/server.js +59 -151
  94. package/server/dist/sync.js +41 -4
  95. package/server/dist/trpc/context.js +7 -0
  96. package/server/dist/trpc/errors.js +21 -0
  97. package/server/dist/trpc/index.js +19 -0
  98. package/server/dist/trpc/router.js +49 -0
  99. package/server/dist/trpc/routers/asmr-library.js +122 -0
  100. package/server/dist/trpc/routers/asmr-progress.js +133 -0
  101. package/server/dist/trpc/routers/asmr.js +87 -0
  102. package/server/dist/trpc/routers/auth.js +247 -0
  103. package/server/dist/trpc/routers/continue-watching.js +83 -0
  104. package/server/dist/trpc/routers/data.js +1039 -0
  105. package/server/dist/trpc/routers/discord.js +288 -0
  106. package/server/dist/trpc/routers/discovery.js +15 -0
  107. package/server/dist/trpc/routers/insights.js +45 -0
  108. package/server/dist/trpc/routers/manga-library.js +280 -0
  109. package/server/dist/trpc/routers/manga-progress.js +181 -0
  110. package/server/dist/trpc/routers/manga.js +201 -0
  111. package/server/dist/{controllers/music.controller.js → trpc/routers/music.js} +235 -295
  112. package/server/dist/trpc/routers/notifications.js +80 -0
  113. package/server/dist/trpc/routers/progress.js +15 -0
  114. package/server/dist/trpc/routers/queue.js +95 -0
  115. package/server/dist/{routes/radio.routes.js → trpc/routers/radio.js} +30 -23
  116. package/server/dist/trpc/routers/settings.js +105 -0
  117. package/server/dist/trpc/routers/tracker.js +219 -0
  118. package/server/dist/trpc/routers/tv-library.js +145 -0
  119. package/server/dist/trpc/routers/tv-progress.js +171 -0
  120. package/server/dist/trpc/routers/tv.js +565 -0
  121. package/server/dist/trpc/routers/watchlist.js +564 -0
  122. package/server/dist/trpc/validation.js +159 -0
  123. package/server/dist/utils/security.utils.js +0 -18
  124. package/server/package.json +5 -10
  125. package/client/dist/assets/Asmr-D0EGRYsv.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-Gs0GmVaT.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-D8hqarVB.js +0 -10
  128. package/client/dist/assets/Home-BnBzjFkW.js +0 -1
  129. package/client/dist/assets/Insights-i9QgY3nE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-xJB8K2YU.js +0 -1
  131. package/client/dist/assets/Mature-DQtXKOXc.js +0 -1
  132. package/client/dist/assets/MediaCard-DpKoY-1j.js +0 -1
  133. package/client/dist/assets/Music-BP43Zqmq.js +0 -1
  134. package/client/dist/assets/OptionListMenu-Bj9ebsW3.js +0 -1
  135. package/client/dist/assets/Player-O2Cy2GF1.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DYZ272Yp.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-lv26-6jp.js +0 -1
  138. package/client/dist/assets/Radio-qQBYnVfY.js +0 -1
  139. package/client/dist/assets/Search-BkvPYN33.js +0 -1
  140. package/client/dist/assets/Settings-CyZEBF6S.js +0 -21
  141. package/client/dist/assets/Trackers-JK-aXBYC.js +0 -2
  142. package/client/dist/assets/Tv-BwXWJxMy.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-Dzyq6ucH.js +0 -1
  145. package/client/dist/assets/TvPopup-PlCU30FO.js +0 -1
  146. package/client/dist/assets/Watchlist-BOEeGXH9.js +0 -1
  147. package/client/dist/assets/index-CtoKRNLc.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-kr3WOIWH.js +0 -1
  149. package/client/dist/assets/useAsmr-CB9THf0m.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +0 -1
  151. package/client/dist/assets/useProviders-B3VB7A9M.js +0 -1
  152. package/client/dist/assets/useTv-BTUKMH7Q.js +0 -1
  153. package/client/dist/assets/useTvLibrary-C0sX-ilZ.js +0 -1
  154. package/server/dist/controllers/asmr-library.controller.js +0 -288
  155. package/server/dist/controllers/auth.controller.js +0 -225
  156. package/server/dist/controllers/data.controller.js +0 -767
  157. package/server/dist/controllers/insights.controller.js +0 -203
  158. package/server/dist/controllers/manga-library.controller.js +0 -350
  159. package/server/dist/controllers/settings.controller.js +0 -609
  160. package/server/dist/controllers/tv-library.controller.js +0 -314
  161. package/server/dist/controllers/watchlist.controller.js +0 -1069
  162. package/server/dist/request-logger.js +0 -16
  163. package/server/dist/routes/asmr-library.routes.js +0 -23
  164. package/server/dist/routes/asmr.routes.js +0 -81
  165. package/server/dist/routes/auth.routes.js +0 -25
  166. package/server/dist/routes/data.routes.js +0 -47
  167. package/server/dist/routes/discord-gateway.routes.js +0 -51
  168. package/server/dist/routes/insights.routes.js +0 -35
  169. package/server/dist/routes/lan-auth.routes.js +0 -46
  170. package/server/dist/routes/manga-library.routes.js +0 -25
  171. package/server/dist/routes/manga.routes.js +0 -141
  172. package/server/dist/routes/music.routes.js +0 -20
  173. package/server/dist/routes/providers.routes.js +0 -17
  174. package/server/dist/routes/proxy.routes.js +0 -12
  175. package/server/dist/routes/settings.routes.js +0 -24
  176. package/server/dist/routes/tracker.routes.js +0 -287
  177. package/server/dist/routes/translate.routes.js +0 -88
  178. package/server/dist/routes/tv-library.routes.js +0 -23
  179. package/server/dist/routes/tv.routes.js +0 -629
  180. package/server/dist/routes/watchlist.routes.js +0 -341
@@ -1,4 +1,4 @@
1
- import { performWriteTransaction } from '../sync.js';
1
+ import { performWriteTransactionAsync } from '../sync.js';
2
2
  import { searchAnilistByTitle, getShowMetaById } from './anilist.js';
3
3
  import { isTempShowId } from './temp-ids.js';
4
4
  import { WatchlistRepository } from '../repositories/watchlist.repository.js';
@@ -7,17 +7,13 @@ import { dbGet, dbRun } from '../utils/db-utils.js';
7
7
  import logger from '../logger.js';
8
8
  const inFlightMigrations = new Map();
9
9
  async function consolidateFromNumeric(db, numericId) {
10
- // Check if this numeric ID might be a MAL alias that should redirect to the canonical AniList ID
11
10
  const metaRow = dbGet(db, 'SELECT anilistId FROM shows_meta WHERE id = ? AND anilistId IS NOT NULL', [numericId]);
12
- // Determine the true anilistId for this numeric ID
13
11
  let trueAnilistId;
14
12
  if (metaRow?.anilistId != null && String(metaRow.anilistId) !== numericId) {
15
- // shows_meta already has a different anilistId — this is clearly a MAL alias
16
13
  trueAnilistId = metaRow.anilistId;
17
14
  }
18
15
  else if (!metaRow) {
19
- // No local shows_meta — only verify via AniList if there's existing data that could be a MAL alias
20
- if (WatchlistRepository.exists(db, numericId)) {
16
+ if (await WatchlistRepository.exists(db, numericId)) {
21
17
  const meta = await getShowMetaById(numericId);
22
18
  if (meta?.anilistId && meta.anilistId !== parseInt(numericId)) {
23
19
  trueAnilistId = meta.anilistId;
@@ -26,12 +22,11 @@ async function consolidateFromNumeric(db, numericId) {
26
22
  }
27
23
  if (trueAnilistId) {
28
24
  const canonicalId = String(trueAnilistId);
29
- if (WatchlistRepository.exists(db, numericId)) {
30
- // MAL alias is in the watchlist — migrate all entries to the canonical AniList ID
25
+ if (await WatchlistRepository.exists(db, numericId)) {
31
26
  logger.info({ aliasId: numericId, canonicalId }, 'Migrating watchlist from MAL alias to canonical ID');
32
- await performWriteTransaction(db, (tx) => {
33
- const aliasWatchlist = WatchlistRepository.getById(tx, numericId);
34
- const aliasShowsMeta = ShowsMetaRepository.getById(tx, numericId);
27
+ await performWriteTransactionAsync(db, async (tx) => {
28
+ const aliasWatchlist = await WatchlistRepository.getById(tx, numericId);
29
+ const aliasShowsMeta = await ShowsMetaRepository.getById(tx, numericId);
35
30
  tx.run('UPDATE OR IGNORE watchlist SET id = ?, thumbnail = ? WHERE id = ?', [
36
31
  canonicalId,
37
32
  aliasWatchlist?.thumbnail || '',
@@ -70,8 +65,7 @@ async function consolidateFromNumeric(db, numericId) {
70
65
  db.scheduleSave();
71
66
  return canonicalId;
72
67
  }
73
- // Not in watchlist — check if canonical ID is in the watchlist and save a mapping
74
- if (WatchlistRepository.exists(db, canonicalId)) {
68
+ if (await WatchlistRepository.exists(db, canonicalId)) {
75
69
  const existing = dbGet(db, 'SELECT numericId FROM legacy_id_mapping WHERE legacyId = ?', [numericId]);
76
70
  if (!existing || existing.numericId !== canonicalId) {
77
71
  dbRun(db, 'INSERT OR REPLACE INTO legacy_id_mapping (legacyId, numericId) VALUES (?, ?)', [
@@ -84,15 +78,13 @@ async function consolidateFromNumeric(db, numericId) {
84
78
  return canonicalId;
85
79
  }
86
80
  }
87
- if (WatchlistRepository.exists(db, numericId))
81
+ if (await WatchlistRepository.exists(db, numericId))
88
82
  return numericId;
89
83
  let legacyId;
90
- // 1. Check legacy_id_mapping for a legacy ID in the watchlist that maps to this numeric ID
91
84
  const legacyRow = dbGet(db, 'SELECT legacyId FROM legacy_id_mapping WHERE numericId = ? AND legacyId IN (SELECT id FROM watchlist)', [numericId]);
92
85
  if (legacyRow) {
93
86
  legacyId = legacyRow.legacyId;
94
87
  }
95
- // 2. If not found, check shows_meta by anilistId where the entry's id IS in the watchlist
96
88
  if (!legacyId && trueAnilistId) {
97
89
  const metaByAnilist = dbGet(db, 'SELECT id FROM shows_meta WHERE anilistId = ? AND id IN (SELECT id FROM watchlist)', [trueAnilistId]);
98
90
  if (metaByAnilist) {
@@ -103,9 +95,9 @@ async function consolidateFromNumeric(db, numericId) {
103
95
  return numericId;
104
96
  logger.info({ legacyId, numericId }, 'Consolidating watchlist entry from legacy to numeric ID');
105
97
  const aniListId = parseInt(numericId);
106
- await performWriteTransaction(db, (tx) => {
107
- const legacyWatchlist = WatchlistRepository.getById(tx, legacyId);
108
- const legacyShowsMeta = ShowsMetaRepository.getById(tx, legacyId);
98
+ await performWriteTransactionAsync(db, async (tx) => {
99
+ const legacyWatchlist = await WatchlistRepository.getById(tx, legacyId);
100
+ const legacyShowsMeta = await ShowsMetaRepository.getById(tx, legacyId);
109
101
  tx.run('UPDATE OR IGNORE watchlist SET id = ?, thumbnail = ? WHERE id = ?', [
110
102
  numericId,
111
103
  legacyWatchlist?.thumbnail || '',
@@ -150,7 +142,6 @@ async function migrateId(db, legacyId) {
150
142
  return consolidateFromNumeric(db, legacyId);
151
143
  }
152
144
  try {
153
- // 1. Check if mapping already exists
154
145
  const mapping = dbGet(db, 'SELECT numericId FROM legacy_id_mapping WHERE legacyId = ?', [legacyId]);
155
146
  if (mapping) {
156
147
  const mappedMeta = (await ShowsMetaRepository.getById(db, mapping.numericId));
@@ -158,9 +149,9 @@ async function migrateId(db, legacyId) {
158
149
  if (!canonicalId || canonicalId === mapping.numericId) {
159
150
  return consolidateFromNumeric(db, mapping.numericId);
160
151
  }
161
- await performWriteTransaction(db, (tx) => {
162
- const legacyWatchlist = WatchlistRepository.getById(tx, mapping.numericId);
163
- const legacyShowsMeta = ShowsMetaRepository.getById(tx, mapping.numericId);
152
+ await performWriteTransactionAsync(db, async (tx) => {
153
+ const legacyWatchlist = await WatchlistRepository.getById(tx, mapping.numericId);
154
+ const legacyShowsMeta = await ShowsMetaRepository.getById(tx, mapping.numericId);
164
155
  tx.run('UPDATE OR IGNORE watchlist SET id = ?, thumbnail = ? WHERE id = ?', [
165
156
  canonicalId,
166
157
  legacyWatchlist?.thumbnail || '',
@@ -203,9 +194,7 @@ async function migrateId(db, legacyId) {
203
194
  logger.info({ legacyId, previousId: mapping.numericId, newId: canonicalId }, 'Canonicalized migrated show ID');
204
195
  return canonicalId;
205
196
  }
206
- // 2. We need to find the title/name of the show
207
197
  let showName;
208
- // Try shows_meta by direct ID or anilistId
209
198
  const localMeta = (await ShowsMetaRepository.getById(db, legacyId));
210
199
  if (localMeta && localMeta.name) {
211
200
  showName = localMeta.name;
@@ -216,7 +205,6 @@ async function migrateId(db, legacyId) {
216
205
  showName = metaByAnilist.name;
217
206
  }
218
207
  else {
219
- // Try watchlist
220
208
  const watchlistEntry = await WatchlistRepository.getById(db, legacyId);
221
209
  if (watchlistEntry && watchlistEntry.name) {
222
210
  showName = watchlistEntry.name;
@@ -227,7 +215,6 @@ async function migrateId(db, legacyId) {
227
215
  logger.warn({ id: legacyId }, 'No show name found for legacy ID migration');
228
216
  return legacyId;
229
217
  }
230
- // 3. Search AniList by title
231
218
  const aniListShow = await searchAnilistByTitle(showName);
232
219
  if (!aniListShow) {
233
220
  logger.warn({ id: legacyId, showName }, 'No AniList match found for legacy ID migration');
@@ -242,17 +229,16 @@ async function migrateId(db, legacyId) {
242
229
  catch {
243
230
  newThumbnail = undefined;
244
231
  }
245
- // 4. Perform the DB updates across all tables atomically
246
- await performWriteTransaction(db, (tx) => {
232
+ await performWriteTransactionAsync(db, async (tx) => {
247
233
  tx.run('INSERT OR REPLACE INTO legacy_id_mapping (legacyId, numericId) VALUES (?, ?)', [
248
234
  legacyId,
249
235
  newId,
250
236
  ]);
251
- const legacyWatchlist = WatchlistRepository.getById(tx, legacyId);
237
+ const legacyWatchlist = await WatchlistRepository.getById(tx, legacyId);
252
238
  if (legacyWatchlist) {
253
- const newWatchlistExists = WatchlistRepository.getById(tx, newId);
239
+ const newWatchlistExists = await WatchlistRepository.getById(tx, newId);
254
240
  if (newWatchlistExists) {
255
- WatchlistRepository.delete(tx, legacyId);
241
+ await WatchlistRepository.delete(tx, legacyId);
256
242
  }
257
243
  else {
258
244
  tx.run('UPDATE watchlist SET id = ?, thumbnail = ? WHERE id = ?', [
@@ -262,9 +248,9 @@ async function migrateId(db, legacyId) {
262
248
  ]);
263
249
  }
264
250
  }
265
- const legacyShowsMeta = ShowsMetaRepository.getById(tx, legacyId);
251
+ const legacyShowsMeta = await ShowsMetaRepository.getById(tx, legacyId);
266
252
  if (legacyShowsMeta) {
267
- const newShowsMetaExists = ShowsMetaRepository.getById(tx, newId);
253
+ const newShowsMetaExists = await ShowsMetaRepository.getById(tx, newId);
268
254
  if (newShowsMetaExists) {
269
255
  tx.run('DELETE FROM shows_meta WHERE id = ?', [legacyId]);
270
256
  }
@@ -122,8 +122,8 @@ class OfflineDb {
122
122
  ensureTable(db);
123
123
  this.loadCache(db);
124
124
  log.info(`OfflineDb loaded ${this.malToEntry.size} MAL mappings from SQLite`);
125
- const wasInterrupted = SettingsRepository.getByKey(db, 'offlineDbLastStatus')?.value === 'updating';
126
- const autoUpdateSetting = SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
125
+ const wasInterrupted = (await SettingsRepository.getByKey(db, 'offlineDbLastStatus'))?.value === 'updating';
126
+ const autoUpdateSetting = await SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
127
127
  const autoUpdateEnabled = !autoUpdateSetting || autoUpdateSetting.value !== 'false';
128
128
  this.backfillShowsMetaGenres(db);
129
129
  if (this.malToEntry.size === 0) {
@@ -132,17 +132,17 @@ class OfflineDb {
132
132
  log.warn({ err: err?.message }, 'Background offline database download failed');
133
133
  });
134
134
  }
135
- else if (wasInterrupted && autoUpdateEnabled && this.interruptedRetryDue(db)) {
135
+ else if (wasInterrupted && autoUpdateEnabled && (await this.interruptedRetryDue(db))) {
136
136
  log.info('Previous offline database refresh was interrupted. Retrying in background...');
137
- SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
138
- SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Retrying automatically...');
137
+ await SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
138
+ await SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Retrying automatically...');
139
139
  this.refreshDatabase(db).catch((err) => {
140
140
  log.warn({ err: err?.message }, 'Interrupted offline database retry failed');
141
141
  });
142
142
  }
143
143
  else if (wasInterrupted) {
144
- SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
145
- SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Trigger an update to retry.');
144
+ await SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
145
+ await SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Trigger an update to retry.');
146
146
  }
147
147
  this.isInitialized = true;
148
148
  }
@@ -214,34 +214,34 @@ class OfflineDb {
214
214
  getMalIdByAnilist(anilistId) {
215
215
  return this.anilistToMal.get(anilistId) ?? null;
216
216
  }
217
- getOfflineDbInfo(db) {
218
- const get = (key) => SettingsRepository.getByKey(db, key)?.value ?? null;
219
- const autoUpdate = get('offlineDbAutoUpdateEnabled');
220
- const lastStatus = get('offlineDbLastStatus');
221
- const entryCount = Number(get('offlineDbEntryCount') ?? this.malToEntry.size) || 0;
217
+ async getOfflineDbInfo(db) {
218
+ const get = async (key) => (await SettingsRepository.getByKey(db, key))?.value ?? null;
219
+ const autoUpdate = await get('offlineDbAutoUpdateEnabled');
220
+ const lastStatus = (await get('offlineDbLastStatus'));
221
+ const entryCount = Number((await get('offlineDbEntryCount')) ?? this.malToEntry.size) || 0;
222
222
  return {
223
223
  totalMapped: this.malToEntry.size,
224
224
  totalMalMapped: this.malToEntry.size,
225
225
  isInitialized: this.isInitialized,
226
226
  isRefreshing: this.isRefreshing,
227
227
  autoUpdateEnabled: autoUpdate ? autoUpdate !== 'false' : true,
228
- lastCheckedAt: get('offlineDbLastCheckedAt'),
229
- lastUpdatedAt: get('offlineDbLastUpdatedAt'),
228
+ lastCheckedAt: await get('offlineDbLastCheckedAt'),
229
+ lastUpdatedAt: await get('offlineDbLastUpdatedAt'),
230
230
  lastStatus: lastStatus || (this.isRefreshing ? 'updating' : 'idle'),
231
- lastMessage: get('offlineDbLastMessage'),
232
- sourceUrl: get('offlineDbSourceUrl'),
233
- releaseTag: get('offlineDbReleaseTag'),
234
- contentSha256: get('offlineDbContentSha256'),
231
+ lastMessage: await get('offlineDbLastMessage'),
232
+ sourceUrl: await get('offlineDbSourceUrl'),
233
+ releaseTag: await get('offlineDbReleaseTag'),
234
+ contentSha256: await get('offlineDbContentSha256'),
235
235
  entryCount,
236
236
  schemaVersion: SCHEMA_VERSION,
237
237
  nextDueAt: latestSaturdayUtc().toISOString(),
238
238
  };
239
239
  }
240
- checkWeeklyUpdateDue(db) {
241
- const autoUpdate = SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
240
+ async checkWeeklyUpdateDue(db) {
241
+ const autoUpdate = await SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
242
242
  if (autoUpdate && autoUpdate.value === 'false')
243
243
  return false;
244
- const lastChecked = SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
244
+ const lastChecked = await SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
245
245
  if (!lastChecked?.value)
246
246
  return true;
247
247
  const lastCheckedTime = new Date(lastChecked.value).getTime();
@@ -249,8 +249,8 @@ class OfflineDb {
249
249
  return true;
250
250
  return lastCheckedTime < latestSaturdayUtc().getTime();
251
251
  }
252
- interruptedRetryDue(db) {
253
- const lastChecked = SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
252
+ async interruptedRetryDue(db) {
253
+ const lastChecked = await SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
254
254
  if (!lastChecked?.value)
255
255
  return true;
256
256
  const lastCheckedTime = new Date(lastChecked.value).getTime();
@@ -276,9 +276,9 @@ class OfflineDb {
276
276
  }
277
277
  this.isRefreshing = true;
278
278
  const startedAt = Date.now();
279
- SettingsRepository.upsert(db, 'offlineDbLastStatus', 'updating');
280
- SettingsRepository.upsert(db, 'offlineDbLastCheckedAt', new Date().toISOString());
281
- SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Downloading and parsing database...');
279
+ await SettingsRepository.upsert(db, 'offlineDbLastStatus', 'updating');
280
+ await SettingsRepository.upsert(db, 'offlineDbLastCheckedAt', new Date().toISOString());
281
+ await SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Downloading and parsing database...');
282
282
  try {
283
283
  let text = null;
284
284
  let sourceUrl = null;
@@ -302,7 +302,7 @@ class OfflineDb {
302
302
  if (!text)
303
303
  throw lastErr || new Error('Could not download anime-offline-database from any source');
304
304
  const sha256 = crypto.createHash('sha256').update(text).digest('hex');
305
- const storedSha = SettingsRepository.getByKey(db, 'offlineDbContentSha256')?.value;
305
+ const storedSha = (await SettingsRepository.getByKey(db, 'offlineDbContentSha256'))?.value;
306
306
  const data = JSON.parse(text).data;
307
307
  log.info(`Parsing ${data.length} entries from anime-offline-database...`);
308
308
  const anilistRegex = /anilist\.co\/anime\/(\d+)/i;
@@ -375,22 +375,22 @@ class OfflineDb {
375
375
  this.loadCache(db);
376
376
  this.backfillShowsMetaGenres(db);
377
377
  const releaseTag = sourceUrl?.includes('/releases/') ? 'latest' : 'raw';
378
- SettingsRepository.upsert(db, 'offlineDbLastStatus', 'success');
379
- SettingsRepository.upsert(db, 'offlineDbLastUpdatedAt', new Date().toISOString());
380
- SettingsRepository.upsert(db, 'offlineDbSourceUrl', sourceUrl ?? '');
381
- SettingsRepository.upsert(db, 'offlineDbReleaseTag', releaseTag);
382
- SettingsRepository.upsert(db, 'offlineDbContentSha256', sha256);
383
- SettingsRepository.upsert(db, 'offlineDbEntryCount', String(parsedEntries.length));
378
+ await SettingsRepository.upsert(db, 'offlineDbLastStatus', 'success');
379
+ await SettingsRepository.upsert(db, 'offlineDbLastUpdatedAt', new Date().toISOString());
380
+ await SettingsRepository.upsert(db, 'offlineDbSourceUrl', sourceUrl ?? '');
381
+ await SettingsRepository.upsert(db, 'offlineDbReleaseTag', releaseTag);
382
+ await SettingsRepository.upsert(db, 'offlineDbContentSha256', sha256);
383
+ await SettingsRepository.upsert(db, 'offlineDbEntryCount', String(parsedEntries.length));
384
384
  const skipped = storedSha === sha256;
385
- SettingsRepository.upsert(db, 'offlineDbLastMessage', `Updated ${parsedEntries.length} entries in ${Math.round((Date.now() - startedAt) / 1000)}s${skipped ? ' (unchanged upstream hash)' : ''}.`);
385
+ await SettingsRepository.upsert(db, 'offlineDbLastMessage', `Updated ${parsedEntries.length} entries in ${Math.round((Date.now() - startedAt) / 1000)}s${skipped ? ' (unchanged upstream hash)' : ''}.`);
386
386
  log.info(`OfflineDb refresh completed: ${parsedEntries.length} entries from ${sourceUrl}`);
387
387
  return { success: true, count: parsedEntries.length };
388
388
  }
389
389
  catch (err) {
390
390
  const errMsg = err.message || 'Failed to refresh offline database';
391
391
  log.error({ err }, 'Failed to refresh anime-offline-database');
392
- SettingsRepository.upsert(db, 'offlineDbLastStatus', 'failed');
393
- SettingsRepository.upsert(db, 'offlineDbLastMessage', errMsg);
392
+ await SettingsRepository.upsert(db, 'offlineDbLastStatus', 'failed');
393
+ await SettingsRepository.upsert(db, 'offlineDbLastMessage', errMsg);
394
394
  return { success: false, count: this.malToEntry.size, error: errMsg };
395
395
  }
396
396
  finally {
@@ -1,7 +1,7 @@
1
1
  import { AniListTracker } from './anilist-tracker.js';
2
2
  import { MangaLibraryRepository, MangaProgressRepository, } from '../../repositories/manga.repository.js';
3
3
  import { SettingsRepository } from '../../repositories/settings.repository.js';
4
- import { performMangaWriteTransaction, performWriteTransaction } from '../../sync.js';
4
+ import { performMangaWriteTransactionAsync, performWriteTransactionAsync } from '../../sync.js';
5
5
  import logger from '../../logger.js';
6
6
  import { searchAnilistMangaByTitle, getMangaMetaById, getMangaMetaByMalId } from '../anilist.js';
7
7
  import { fetchMalMangaList, mapMalMangaStatusCode } from '../mal.js';
@@ -557,19 +557,19 @@ export async function syncAniListManga(db, mangaDb, opts = {}) {
557
557
  progressRows.length > 0 ||
558
558
  libraryDeletes.length > 0 ||
559
559
  newLinks.length > 0) {
560
- await performMangaWriteTransaction(mangaDb, (tx) => {
560
+ await performMangaWriteTransactionAsync(mangaDb, async (tx) => {
561
561
  for (const item of libraryUpserts) {
562
- MangaLibraryRepository.upsert(tx, item);
562
+ await MangaLibraryRepository.upsert(tx, item);
563
563
  }
564
564
  for (const link of newLinks) {
565
- MangaLibraryRepository.setAnilistId(tx, link.id, link.anilistId, 'search');
565
+ await MangaLibraryRepository.setAnilistId(tx, link.id, link.anilistId, 'search');
566
566
  }
567
567
  for (const p of progressPointers) {
568
- MangaLibraryRepository.setProgressPointer(tx, p.id, p.chapterNumber);
568
+ await MangaLibraryRepository.setProgressPointer(tx, p.id, p.chapterNumber);
569
569
  }
570
570
  for (const p of progressRows) {
571
- MangaProgressRepository.deleteSyntheticChapters(tx, p.mangaId, p.chapterId);
572
- MangaProgressRepository.upsert(tx, {
571
+ await MangaProgressRepository.deleteSyntheticChapters(tx, p.mangaId, p.chapterId);
572
+ await MangaProgressRepository.upsert(tx, {
573
573
  mangaId: p.mangaId,
574
574
  chapterId: p.chapterId,
575
575
  chapterNumber: p.chapterNumber,
@@ -581,8 +581,8 @@ export async function syncAniListManga(db, mangaDb, opts = {}) {
581
581
  });
582
582
  }
583
583
  for (const id of libraryDeletes) {
584
- MangaLibraryRepository.delete(tx, id);
585
- MangaProgressRepository.deleteByManga(tx, id);
584
+ await MangaLibraryRepository.delete(tx, id);
585
+ await MangaProgressRepository.deleteByManga(tx, id);
586
586
  }
587
587
  });
588
588
  }
@@ -596,8 +596,8 @@ export async function syncAniListManga(db, mangaDb, opts = {}) {
596
596
  mergedState[k] = v;
597
597
  }
598
598
  const snapshot = JSON.stringify(mergedState);
599
- await performWriteTransaction(db, (tx) => {
600
- SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
599
+ await performWriteTransactionAsync(db, async (tx) => {
600
+ await SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
601
601
  });
602
602
  }
603
603
  catch (err) {
@@ -619,19 +619,19 @@ export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistR
619
619
  return 0;
620
620
  const now = Math.floor(Date.now() / 1000);
621
621
  const syncState = await readMangaSyncState(db);
622
- await performMangaWriteTransaction(mangaDb, (tx) => {
622
+ await performMangaWriteTransactionAsync(mangaDb, async (tx) => {
623
623
  if (eraseAnilistRows) {
624
- for (const row of MangaLibraryRepository.getAll(tx)) {
624
+ for (const row of await MangaLibraryRepository.getAll(tx)) {
625
625
  if (String(row.provider).toLowerCase() === MANGA_PROVIDER) {
626
- MangaLibraryRepository.delete(tx, row.id);
627
- MangaProgressRepository.deleteByManga(tx, row.id);
626
+ await MangaLibraryRepository.delete(tx, row.id);
627
+ await MangaProgressRepository.deleteByManga(tx, row.id);
628
628
  }
629
629
  }
630
630
  }
631
631
  for (const item of items) {
632
632
  const id = buildAnilistMangaId(item.mediaId);
633
633
  const status = mangaImportStatusOrDefault(item.status);
634
- MangaLibraryRepository.upsert(tx, {
634
+ await MangaLibraryRepository.upsert(tx, {
635
635
  id,
636
636
  provider: MANGA_PROVIDER,
637
637
  mangaId: String(item.mediaId),
@@ -642,10 +642,10 @@ export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistR
642
642
  anilistIdSource: 'anilist',
643
643
  });
644
644
  if (item.progress > 0) {
645
- MangaLibraryRepository.setProgressPointer(tx, id, String(item.progress));
645
+ await MangaLibraryRepository.setProgressPointer(tx, id, String(item.progress));
646
646
  const chapterId = buildSyntheticChapterId(item.progress);
647
- MangaProgressRepository.deleteSyntheticChapters(tx, id, chapterId);
648
- MangaProgressRepository.upsert(tx, {
647
+ await MangaProgressRepository.deleteSyntheticChapters(tx, id, chapterId);
648
+ await MangaProgressRepository.upsert(tx, {
649
649
  mangaId: id,
650
650
  chapterId,
651
651
  chapterNumber: String(item.progress),
@@ -664,8 +664,8 @@ export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistR
664
664
  });
665
665
  try {
666
666
  const snapshot = JSON.stringify(syncState);
667
- await performWriteTransaction(db, (tx) => {
668
- SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
667
+ await performWriteTransactionAsync(db, async (tx) => {
668
+ await SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
669
669
  });
670
670
  }
671
671
  catch (err) {
@@ -4,7 +4,7 @@ import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.r
4
4
  import { ShowsMetaRepository } from '../../repositories/shows-meta.repository.js';
5
5
  import { SettingsRepository } from '../../repositories/settings.repository.js';
6
6
  import { NotificationsRepository } from '../../repositories/notifications.repository.js';
7
- import { performWriteTransaction } from '../../sync.js';
7
+ import { performWriteTransactionAsync } from '../../sync.js';
8
8
  import { dbGet } from '../../utils/db-utils.js';
9
9
  import logger from '../../logger.js';
10
10
  import { fetchMalUserList } from '../mal.js';
@@ -242,16 +242,16 @@ export async function syncAniList(db) {
242
242
  watchedInserts.length > 0 ||
243
243
  watchlistDeletes.length > 0 ||
244
244
  Object.keys(stateUpdates).length > 0) {
245
- await performWriteTransaction(db, (tx) => {
245
+ await performWriteTransactionAsync(db, async (tx) => {
246
246
  for (const item of watchlistUpserts) {
247
- WatchlistRepository.upsert(tx, item);
247
+ await WatchlistRepository.upsert(tx, item);
248
248
  }
249
249
  for (const meta of metaUpserts) {
250
- ShowsMetaRepository.upsert(tx, meta);
250
+ await ShowsMetaRepository.upsert(tx, meta);
251
251
  }
252
252
  for (const w of watchedInserts) {
253
253
  for (let ep = w.from; ep <= w.to; ep++) {
254
- WatchedEpisodesRepository.insertIfMissing(tx, {
254
+ await WatchedEpisodesRepository.insertIfMissing(tx, {
255
255
  showId: w.showId,
256
256
  episodeNumber: String(ep),
257
257
  watchedAt: toSqliteDatetime(w.updatedAt),
@@ -259,9 +259,9 @@ export async function syncAniList(db) {
259
259
  }
260
260
  }
261
261
  for (const id of watchlistDeletes) {
262
- WatchlistRepository.delete(tx, id);
263
- WatchedEpisodesRepository.deleteByShow(tx, id);
264
- NotificationsRepository.deleteByShow(tx, id);
262
+ await WatchlistRepository.delete(tx, id);
263
+ await WatchedEpisodesRepository.deleteByShow(tx, id);
264
+ await NotificationsRepository.deleteByShow(tx, id);
265
265
  }
266
266
  const mergedState = { ...syncState };
267
267
  for (const [k, v] of Object.entries(stateUpdates)) {
@@ -270,7 +270,7 @@ export async function syncAniList(db) {
270
270
  else
271
271
  mergedState[k] = v;
272
272
  }
273
- SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(mergedState));
273
+ await SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(mergedState));
274
274
  });
275
275
  }
276
276
  return summary;
@@ -300,13 +300,13 @@ export async function importFromUsername(db, username, erase = false) {
300
300
  return 0;
301
301
  const now = Math.floor(Date.now() / 1000);
302
302
  const syncState = await readSyncState(db);
303
- await performWriteTransaction(db, (tx) => {
303
+ await performWriteTransactionAsync(db, async (tx) => {
304
304
  if (erase)
305
- SettingsRepository.clearWatchlist(tx);
305
+ await SettingsRepository.clearWatchlist(tx);
306
306
  for (const remote of entries) {
307
307
  const showId = String(remote.mediaId);
308
308
  const title = remote.title.english || remote.title.romaji || `Anime #${remote.mediaId}`;
309
- WatchlistRepository.upsert(tx, {
309
+ await WatchlistRepository.upsert(tx, {
310
310
  id: showId,
311
311
  name: title,
312
312
  thumbnail: remote.coverImage ?? '',
@@ -315,7 +315,7 @@ export async function importFromUsername(db, username, erase = false) {
315
315
  englishName: remote.title.english ?? '',
316
316
  type: 'TV',
317
317
  });
318
- ShowsMetaRepository.upsert(tx, {
318
+ await ShowsMetaRepository.upsert(tx, {
319
319
  id: showId,
320
320
  name: title,
321
321
  thumbnail: remote.coverImage,
@@ -326,7 +326,7 @@ export async function importFromUsername(db, username, erase = false) {
326
326
  });
327
327
  if (remote.progress > 0) {
328
328
  for (let ep = 1; ep <= remote.progress; ep++) {
329
- WatchedEpisodesRepository.insertIfMissing(tx, {
329
+ await WatchedEpisodesRepository.insertIfMissing(tx, {
330
330
  showId,
331
331
  episodeNumber: String(ep),
332
332
  watchedAt: toSqliteDatetime(remote.updatedAt),
@@ -335,7 +335,7 @@ export async function importFromUsername(db, username, erase = false) {
335
335
  }
336
336
  syncState[showId] = { lastSyncedAt: now, remoteUpdatedAt: remote.updatedAt };
337
337
  }
338
- SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(syncState));
338
+ await SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(syncState));
339
339
  });
340
340
  return entries.length;
341
341
  }
@@ -458,11 +458,11 @@ export async function importFromMalUsername(db, username, options = {}) {
458
458
  }
459
459
  }
460
460
  if (shows.length > 0 || erase) {
461
- await performWriteTransaction(db, (tx) => {
461
+ await performWriteTransactionAsync(db, async (tx) => {
462
462
  if (erase)
463
- SettingsRepository.clearWatchlist(tx);
463
+ await SettingsRepository.clearWatchlist(tx);
464
464
  for (const show of shows) {
465
- WatchlistRepository.upsert(tx, {
465
+ await WatchlistRepository.upsert(tx, {
466
466
  id: show.id,
467
467
  name: show.name,
468
468
  thumbnail: show.thumbnail ?? '',
@@ -474,7 +474,7 @@ export async function importFromMalUsername(db, username, options = {}) {
474
474
  }
475
475
  for (const meta of metas) {
476
476
  if (meta.thumbnail || meta.type || meta.genres) {
477
- ShowsMetaRepository.upsert(tx, {
477
+ await ShowsMetaRepository.upsert(tx, {
478
478
  id: meta.id,
479
479
  thumbnail: meta.thumbnail,
480
480
  type: meta.type,
@@ -484,7 +484,7 @@ export async function importFromMalUsername(db, username, options = {}) {
484
484
  }
485
485
  for (const w of watched) {
486
486
  for (let ep = 1; ep <= w.progress; ep++) {
487
- WatchedEpisodesRepository.insertIfMissing(tx, {
487
+ await WatchedEpisodesRepository.insertIfMissing(tx, {
488
488
  showId: w.showId,
489
489
  episodeNumber: String(ep),
490
490
  });
@@ -0,0 +1,79 @@
1
+ import { AppCache } from '../utils/cache.utils.js';
2
+ import { parseJsonBody } from '../utils/http.utils.js';
3
+ const cache = new AppCache({
4
+ ttlSeconds: 7 * 24 * 60 * 60,
5
+ maxKeys: 5000,
6
+ });
7
+ function toTitleCase(s) {
8
+ return s
9
+ .split(' ')
10
+ .map((w, i) => {
11
+ if (i !== 0 &&
12
+ ['x', 'and', 'or', 'the', 'a', 'an', 'of', 'in', 'on', 'with'].includes(w.toLowerCase())) {
13
+ return w.toLowerCase();
14
+ }
15
+ return w.charAt(0).toUpperCase() + w.slice(1);
16
+ })
17
+ .join(' ')
18
+ .replace(/\bX\b/g, 'x');
19
+ }
20
+ function normalize(s) {
21
+ const t = s.trim();
22
+ if (!t)
23
+ return t;
24
+ return toTitleCase(t);
25
+ }
26
+ export async function translateTexts(texts, source, target) {
27
+ const jobs = texts.map((t) => String(t).trim()).filter(Boolean);
28
+ const result = {};
29
+ const toFetch = [];
30
+ for (const t of jobs) {
31
+ const key = `${source}:${target}:${t}`;
32
+ const cached = cache.get(key);
33
+ if (cached)
34
+ result[t] = cached;
35
+ else
36
+ toFetch.push(t);
37
+ }
38
+ if (toFetch.length > 0) {
39
+ const chunks = [];
40
+ for (let i = 0; i < toFetch.length; i += 15)
41
+ chunks.push(toFetch.slice(i, i + 15));
42
+ for (const chunk of chunks) {
43
+ const q = chunk.join('\n');
44
+ const url = `https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl=${encodeURIComponent(source)}&tl=${encodeURIComponent(target)}&dt=t&q=${encodeURIComponent(q)}`;
45
+ try {
46
+ const r = await fetch(url, {
47
+ headers: { 'User-Agent': 'Mozilla/5.0' },
48
+ signal: AbortSignal.timeout(5000),
49
+ });
50
+ if (!r.ok)
51
+ throw new Error(`translate ${r.status}`);
52
+ const data = (await parseJsonBody(r));
53
+ let translated = '';
54
+ if (Array.isArray(data) && typeof data[0] === 'string') {
55
+ translated = data[0];
56
+ }
57
+ else if (Array.isArray(data) && Array.isArray(data[0])) {
58
+ const outer = data;
59
+ translated = outer[0]?.map((p) => p[0]).join('') || '';
60
+ }
61
+ const parts = translated.split('\n');
62
+ chunk.forEach((orig, i) => {
63
+ const trans = (parts[i] ?? '').trim();
64
+ const norm = trans ? normalize(trans) : orig;
65
+ result[orig] = norm;
66
+ cache.set(`${source}:${target}:${orig}`, norm);
67
+ });
68
+ }
69
+ catch {
70
+ for (const orig of chunk) {
71
+ if (!result[orig])
72
+ result[orig] = orig;
73
+ }
74
+ }
75
+ await new Promise((r) => setTimeout(r, 80));
76
+ }
77
+ }
78
+ return result;
79
+ }