@serifpersia/dango 3.4.1 → 3.4.3

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 (109) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-B0oMgXBm.js → AnimeCard-Chlfp7Cm.js} +1 -1
  3. package/client/dist/assets/AnimeInfo-C1XZcEzj.js +1 -0
  4. package/client/dist/assets/AnimeInfoPage-BvNYYbe1.js +2 -0
  5. package/client/dist/assets/{AnimeMetaDetails-BPvv5xIl.js → AnimeMetaDetails-BogOi_bM.js} +1 -1
  6. package/client/dist/assets/Asmr-_jVaYDfT.js +2 -0
  7. package/client/dist/assets/{AsmrResetProgressModal-C9R7HlNa.js → AsmrResetProgressModal-ngn9hdA0.js} +1 -1
  8. package/client/dist/assets/AvSyncCalibrator-C2wu1ydI.css +1 -0
  9. package/client/dist/assets/AvSyncCalibrator-Rcv7aubm.js +10 -0
  10. package/client/dist/assets/{ErrorMessage-z4f4WGgP.js → ErrorMessage-DAxegITP.js} +1 -1
  11. package/client/dist/assets/Home-B6bte0tw.css +1 -0
  12. package/client/dist/assets/Home-CEbLBbmD.js +1 -0
  13. package/client/dist/assets/Insights-Bia4rfp0.js +1 -0
  14. package/client/dist/assets/{LibraryListPage-B_r51cRi.js → LibraryListPage-BVHcMPqZ.js} +1 -1
  15. package/client/dist/assets/{ListeningList-BWnwvJTt.js → ListeningList-DpRNQKk7.js} +1 -1
  16. package/client/dist/assets/{Manga-CI5cZg3h.js → Manga-wHddjSTh.js} +1 -1
  17. package/client/dist/assets/MangaInfoPage-CVz8z5wn.js +1 -0
  18. package/client/dist/assets/MangaReadPage-CC3cgtIZ.js +1 -0
  19. package/client/dist/assets/{MangaResetProgressModal-CPMEqZyi.js → MangaResetProgressModal-DD7a0log.js} +1 -1
  20. package/client/dist/assets/{Map-Dj51g5zL.js → Map-BtXwpBOC.js} +1 -1
  21. package/client/dist/assets/Mature-D8uFpIDj.js +1 -0
  22. package/client/dist/assets/{MediaCard-DzA336CA.js → MediaCard-6Yy_-Whn.js} +1 -1
  23. package/client/dist/assets/Music-BgM0bcAU.js +1 -0
  24. package/client/dist/assets/{OptionListMenu-CpwxAC3J.js → OptionListMenu-C9saW22f.js} +1 -1
  25. package/client/dist/assets/Player-EwAWFxqQ.js +56 -0
  26. package/client/dist/assets/{PlayerRedirect-D70t8VUW.js → PlayerRedirect-fv2tzEUu.js} +1 -1
  27. package/client/dist/assets/PlayerSettings-BYqhlu3F.js +1 -0
  28. package/client/dist/assets/{QueueOptionsButton-1M7MdKzM.js → QueueOptionsButton-BJhkcl2X.js} +1 -1
  29. package/client/dist/assets/{QueueRail-Bpla2xD8.js → QueueRail-BLfkt_s5.js} +1 -1
  30. package/client/dist/assets/Radio-D2Er2JEU.js +1 -0
  31. package/client/dist/assets/{ReadingList-wJMDUybm.js → ReadingList-DWOnwYO1.js} +1 -1
  32. package/client/dist/assets/{ResetProgressModal-CByRM1Fh.js → ResetProgressModal-HUj5zATK.js} +1 -1
  33. package/client/dist/assets/{Search-B94D0tRM.js → Search-CzD9J9ip.js} +1 -1
  34. package/client/dist/assets/Settings-BgUothvZ.js +21 -0
  35. package/client/dist/assets/{SkeletonGrid-BJyZkg_K.js → SkeletonGrid-C7Gj9ODL.js} +1 -1
  36. package/client/dist/assets/{SynopsisText-BcpwT5iw.js → SynopsisText-B3Sa5pRv.js} +1 -1
  37. package/client/dist/assets/{Trackers-vz-gqxtm.js → Trackers-BoC2z0Nd.js} +2 -2
  38. package/client/dist/assets/Tv-Bx7tGS57.css +1 -0
  39. package/client/dist/assets/Tv-Q8TIMWaP.js +4 -0
  40. package/client/dist/assets/TvInfo-C6JoNbev.js +1 -0
  41. package/client/dist/assets/{TvPopup-CqlVRF-Y.js → TvPopup-BArrygql.js} +1 -1
  42. package/client/dist/assets/{TvSearch-BcKXQue9.js → TvSearch-BsoJ7WZp.js} +1 -1
  43. package/client/dist/assets/{TvWatchlist-sjsiup2h.js → TvWatchlist-BFmAd2fE.js} +1 -1
  44. package/client/dist/assets/{VirtualKeyboard-6ZNTym4R.js → VirtualKeyboard-BmqZPZXR.js} +1 -1
  45. package/client/dist/assets/{Watchlist-DAMdvt7Z.js → Watchlist-DG5Y--YA.js} +1 -1
  46. package/client/dist/assets/{hls-Do_n10_Y.js → hls-QeZPjik8.js} +1 -1
  47. package/client/dist/assets/index-Cc1Up_NI.css +1 -0
  48. package/client/dist/assets/index-Rm4h42Uz.js +178 -0
  49. package/client/dist/assets/{useAnimeInfoData-DU9X90no.js → useAnimeInfoData-B9O3Rr_u.js} +1 -1
  50. package/client/dist/assets/useAsmr-Da7AVA9e.js +1 -0
  51. package/client/dist/assets/{useAsmrPopup-DK1Vx-HV.js → useAsmrPopup-B_S5s4q3.js} +1 -1
  52. package/client/dist/assets/{useIsMobile-Dm_b6Ob3.js → useIsMobile-Dvx1uJ82.js} +1 -1
  53. package/client/dist/assets/useMangaLibrary-COpztbMg.js +1 -0
  54. package/client/dist/assets/{useMangaPopup-BLwvRZn0.js → useMangaPopup-BJeHmEpe.js} +1 -1
  55. package/client/dist/assets/{useProviders-D3L1Chqc.js → useProviders-BWRYjB77.js} +1 -1
  56. package/client/dist/assets/useTv-DtiSJ756.js +1 -0
  57. package/client/dist/assets/useTvLibrary-DFfgk9YS.js +1 -0
  58. package/client/dist/assets/{vendor-core-8R2fFbyN.js → vendor-core-DfaOB3Rn.js} +1 -1
  59. package/client/dist/assets/{vendor-icons-CDMuNDeL.js → vendor-icons-DrybRNxP.js} +1 -1
  60. package/client/dist/assets/{vendor-query-BUTye89G.js → vendor-query-BBT5_sHx.js} +1 -1
  61. package/client/dist/assets/{vendor-ui-CUDCvRS_.js → vendor-ui-3qrARCPr.js} +1 -1
  62. package/client/dist/index.html +5 -5
  63. package/orchestrator.js +0 -1
  64. package/package.json +3 -3
  65. package/server/dist/app-hono.js +15 -37
  66. package/server/dist/db/drizzle.js +2 -18
  67. package/server/dist/db.js +3 -2
  68. package/server/dist/hono/tv.js +9 -5
  69. package/server/dist/hono/watchlist.js +10 -108
  70. package/server/dist/lib/migration.js +0 -5
  71. package/server/dist/providers/remote-loader.js +40 -1
  72. package/server/dist/providers/remote-types.js +9 -2
  73. package/server/dist/server.js +50 -182
  74. package/server/dist/sync.js +173 -704
  75. package/server/dist/trpc/routers/auth.js +2 -71
  76. package/server/dist/trpc/routers/continue-watching.js +0 -2
  77. package/server/dist/trpc/routers/data.js +0 -1
  78. package/server/dist/trpc/routers/discord.js +8 -0
  79. package/server/dist/trpc/routers/progress.js +95 -1
  80. package/server/dist/trpc/routers/queue.js +0 -2
  81. package/server/dist/trpc/routers/watchlist.js +0 -7
  82. package/server/dist/trpc/validation.js +64 -0
  83. package/server/package.json +2 -2
  84. package/client/dist/assets/AnimeInfo-o0Udf0og.js +0 -1
  85. package/client/dist/assets/AnimeInfoPage-CojMIEuf.js +0 -2
  86. package/client/dist/assets/Asmr-2V3yCCiK.js +0 -2
  87. package/client/dist/assets/AvSyncCalibrator-BPa6KaGC.css +0 -1
  88. package/client/dist/assets/AvSyncCalibrator-Bo3820j3.js +0 -10
  89. package/client/dist/assets/Home-C8uXTzIM.js +0 -1
  90. package/client/dist/assets/Home-DtRtTuI9.css +0 -1
  91. package/client/dist/assets/Insights-CRqBfZuK.js +0 -1
  92. package/client/dist/assets/MangaInfoPage-BtkOrO34.js +0 -1
  93. package/client/dist/assets/MangaReadPage-Uv72AYdY.js +0 -1
  94. package/client/dist/assets/Mature-DXHWvoY5.js +0 -1
  95. package/client/dist/assets/Music-C4dM0BQx.js +0 -1
  96. package/client/dist/assets/Player-CpMvWTkH.js +0 -56
  97. package/client/dist/assets/PlayerSettings-pMU85k7p.js +0 -1
  98. package/client/dist/assets/Radio-DlmTC-qG.js +0 -1
  99. package/client/dist/assets/Settings-CcU3oNY8.js +0 -21
  100. package/client/dist/assets/Tv-Rji_lvQo.js +0 -4
  101. package/client/dist/assets/Tv-ZEA45gXK.css +0 -1
  102. package/client/dist/assets/TvInfo-Ciujd6os.js +0 -1
  103. package/client/dist/assets/index-B5SLDCAi.js +0 -178
  104. package/client/dist/assets/index-CUm1meqr.css +0 -1
  105. package/client/dist/assets/useAsmr-D6cSjbvt.js +0 -1
  106. package/client/dist/assets/useMangaLibrary-DcxPn0RM.js +0 -1
  107. package/client/dist/assets/useMatureConsent-CT7-KfzG.js +0 -1
  108. package/client/dist/assets/useTv-B546u-Rx.js +0 -1
  109. package/client/dist/assets/useTvLibrary-C2rPeCVT.js +0 -1
package/server/dist/db.js CHANGED
@@ -27,8 +27,9 @@ export class DatabaseWrapper {
27
27
  throw e;
28
28
  }
29
29
  }
30
- scheduleSave() { }
31
- async saveNow() { }
30
+ getClient() {
31
+ return this.db;
32
+ }
32
33
  configure(option, value) {
33
34
  if (option === 'busyTimeout') {
34
35
  this.db.exec(`PRAGMA busy_timeout = ${value}`);
@@ -1,3 +1,4 @@
1
+ import logger from '../logger.js';
1
2
  import { parseJsonBody } from '../utils/http.utils.js';
2
3
  import { getTmdbKey, TMDB_BASE, TMDB_IMAGE } from '../lib/tmdb.js';
3
4
  import { URL } from 'url';
@@ -161,7 +162,8 @@ export function registerTv(app, getApiCache, getTvProvider) {
161
162
  const chunks = [];
162
163
  const reader = fetchResp.body?.getReader();
163
164
  if (!reader) {
164
- return c.text('No response body', 500);
165
+ logger.warn({ host: safeCheck.url?.hostname }, 'tv stream-proxy: upstream sent no body');
166
+ return c.text('Upstream sent no body', 502);
165
167
  }
166
168
  try {
167
169
  while (true) {
@@ -181,14 +183,16 @@ export function registerTv(app, getApiCache, getTvProvider) {
181
183
  }
182
184
  return new Response(body, { status, headers: outHeaders });
183
185
  }
184
- catch {
185
- return c.text('Proxy error', 500);
186
+ catch (err) {
187
+ logger.warn({ err, host: safeCheck.url?.hostname }, 'tv stream-proxy: upstream body stream failed');
188
+ return c.text('Upstream stream failed', 502);
186
189
  }
187
190
  }
188
- catch {
191
+ catch (err) {
189
192
  if (abort.signal.aborted)
190
193
  return new Response(null, { status: 499 });
191
- return c.text('Proxy error', 500);
194
+ logger.warn({ err, host: safeCheck.url?.hostname }, 'tv stream-proxy: upstream fetch failed');
195
+ return c.text('Upstream fetch failed', 502);
192
196
  }
193
197
  finally {
194
198
  clearTimeout(timeout);
@@ -1,14 +1,12 @@
1
1
  import logger from '../logger.js';
2
- import { performWriteTransactionAsync } from '../sync.js';
3
2
  import { WatchlistRepository } from '../repositories/watchlist.repository.js';
4
3
  import { WatchedEpisodesRepository, } from '../repositories/watched-episodes.repository.js';
5
4
  import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
6
5
  import { NotificationsRepository } from '../repositories/notifications.repository.js';
7
6
  import { SettingsRepository } from '../repositories/settings.repository.js';
8
- import { discordRPCService } from '../discord-rpc.js';
9
- import { dbAll, dbGet } from '../utils/db-utils.js';
7
+ import { dbAll } from '../utils/db-utils.js';
8
+ import { AppCache } from '../utils/cache.utils.js';
10
9
  import { searchAnilist, getAiredEpisodesForShows, getAnilistEpisodes, getShowMetaById, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
11
- import { getMigratedId } from '../lib/migration.js';
12
10
  import { offlineDb } from '../lib/offline-db.js';
13
11
  import { parseJsonBody } from '../utils/http.utils.js';
14
12
  import { pickBestMatch } from '../providers/title-matching.js';
@@ -147,8 +145,6 @@ export function startWatchlistDiscovery(getDb) {
147
145
  persisted++;
148
146
  }
149
147
  }
150
- if (persisted > 0)
151
- db.scheduleSave();
152
148
  }
153
149
  if (finishedShowIds.size > 0) {
154
150
  const monthStart = new Date(now);
@@ -192,8 +188,6 @@ export function startWatchlistDiscovery(getDb) {
192
188
  inserted = true;
193
189
  }
194
190
  }
195
- if (inserted)
196
- db.scheduleSave();
197
191
  }
198
192
  }
199
193
  const episodesByShow = new Map();
@@ -229,8 +223,6 @@ export function startWatchlistDiscovery(getDb) {
229
223
  inserted = true;
230
224
  }
231
225
  }
232
- if (inserted)
233
- db.scheduleSave();
234
226
  }
235
227
  await NotificationsRepository.cleanupWatchedNotifications(db);
236
228
  }
@@ -474,7 +466,6 @@ async function backfillMissingPosters(db, rows) {
474
466
  // ignore
475
467
  }
476
468
  }));
477
- db.scheduleSave();
478
469
  await sweepOfflinePosters(db);
479
470
  }
480
471
  async function sweepOfflinePosters(db) {
@@ -498,7 +489,6 @@ async function sweepOfflinePosters(db) {
498
489
  if (poster)
499
490
  await applyPoster(db, id, poster);
500
491
  }
501
- db.scheduleSave();
502
492
  }
503
493
  function resolveOfflinePoster(id) {
504
494
  if (/^\d+$/.test(id)) {
@@ -563,15 +553,15 @@ async function resolveAvailableEpisodes(db, showId) {
563
553
  : [];
564
554
  return episodes;
565
555
  }
566
- const dlsitePosterCache = new Map();
567
- const mangadexCoverCache = new Map();
556
+ const dlsitePosterCache = new AppCache({ ttlSeconds: 3600, maxKeys: 2000 });
557
+ const mangadexCoverCache = new AppCache({ ttlSeconds: 3600, maxKeys: 2000 });
568
558
  export async function getMangadexCover(title) {
569
559
  const key = title.trim().toLowerCase();
570
560
  if (!key)
571
561
  return null;
572
562
  const cached = mangadexCoverCache.get(key);
573
- if (cached && Date.now() - cached.ts < 3600_000)
574
- return cached.url;
563
+ if (cached !== undefined)
564
+ return cached;
575
565
  try {
576
566
  const params = new URLSearchParams({
577
567
  title: title.trim(),
@@ -607,7 +597,7 @@ export async function getMangadexCover(title) {
607
597
  }
608
598
  const match = pickBestMatch(candidates, [title]);
609
599
  const url = match ? match.item.cover : null;
610
- mangadexCoverCache.set(key, { url, ts: Date.now() });
600
+ mangadexCoverCache.set(key, url);
611
601
  return url;
612
602
  }
613
603
  catch {
@@ -619,8 +609,8 @@ export async function getDlsitePoster(rjCode) {
619
609
  if (!/^RJ\d{5,}$/.test(key))
620
610
  return null;
621
611
  const cached = dlsitePosterCache.get(key);
622
- if (cached && Date.now() - cached.ts < 3600_000)
623
- return cached.url;
612
+ if (cached !== undefined)
613
+ return cached;
624
614
  try {
625
615
  const res = await fetch(`https://www.dlsite.com/maniax/product/info/ajax?product_id=${key}`, {
626
616
  headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' },
@@ -636,98 +626,10 @@ export async function getDlsitePoster(rjCode) {
636
626
  if (!img)
637
627
  return null;
638
628
  const url = img.startsWith('//') ? `https:${img}` : img;
639
- dlsitePosterCache.set(key, { url, ts: Date.now() });
629
+ dlsitePosterCache.set(key, url);
640
630
  return url;
641
631
  }
642
632
  catch {
643
633
  return null;
644
634
  }
645
635
  }
646
- export function registerWatchlist(app, getDbs) {
647
- const db = () => getDbs().db;
648
- app.post('/api/update-progress', async (c) => {
649
- const { showId: showIdRaw, episodeNumber, currentTime, duration, showName, showThumbnail, nativeName, englishName, genres, popularityScore, type, status, episodeCount, isPlaying, sessionId, isAdult, } = (await c.req.json());
650
- const showId = await getMigratedId(db(), showIdRaw);
651
- const titlePreferenceRow = await SettingsRepository.getByKey(db(), 'titlePreference');
652
- const titlePreference = titlePreferenceRow ? titlePreferenceRow.value : 'englishName';
653
- let displayName = showName;
654
- if (titlePreference === 'englishName' && englishName) {
655
- displayName = englishName;
656
- }
657
- else if (titlePreference === 'nativeName' && nativeName) {
658
- displayName = nativeName;
659
- }
660
- discordRPCService.updatePresence({
661
- title: displayName,
662
- episode: String(episodeNumber),
663
- totalEpisodes: episodeCount ? String(episodeCount) : undefined,
664
- currentTime: currentTime || 0,
665
- duration: duration || 0,
666
- thumbnail: showThumbnail || '',
667
- isPlaying: isPlaying !== false,
668
- sessionId: sessionId,
669
- isAdult: isAdult,
670
- });
671
- const genresStr = Array.isArray(genres) ? JSON.stringify(genres) : genres;
672
- const anilistId = /^\d+$/.test(showId)
673
- ? (dbGet(db(), 'SELECT anilistId FROM shows_meta WHERE id = ? AND anilistId IS NOT NULL', [showId])?.anilistId ?? parseInt(showId))
674
- : undefined;
675
- const metaCandidate = {
676
- name: showName,
677
- thumbnail: showThumbnail,
678
- nativeName: nativeName,
679
- englishName: englishName,
680
- genres: genresStr,
681
- popularityScore: popularityScore,
682
- status: status,
683
- episodeCount: episodeCount,
684
- type: type,
685
- anilistId,
686
- isAdult: typeof isAdult === 'boolean' ? (isAdult ? 1 : 0) : null,
687
- episodeDuration: duration > 0 ? Math.round(duration / 60) : undefined,
688
- };
689
- const metaChanged = await showsMetaChanged(db(), showId, metaCandidate);
690
- if (metaChanged) {
691
- await performWriteTransactionAsync(db(), async (tx) => {
692
- await ShowsMetaRepository.upsert(tx, {
693
- id: showId,
694
- ...metaCandidate,
695
- });
696
- });
697
- }
698
- await performWriteTransactionAsync(db(), async (tx) => {
699
- await WatchedEpisodesRepository.upsert(tx, {
700
- showId,
701
- episodeNumber: episodeNumber,
702
- currentTime: currentTime,
703
- duration: duration,
704
- });
705
- await NotificationsRepository.deleteSpecificDismissed(tx, showId, episodeNumber);
706
- await NotificationsRepository.deleteDiscovered(tx, showId, episodeNumber);
707
- });
708
- db().scheduleSave();
709
- return c.json({ success: true });
710
- });
711
- app.post('/api/discord/clear', async (c) => {
712
- const { sessionId } = (await c.req.json());
713
- if (!discordRPCService.isServiceEnabled) {
714
- return c.json({ success: true });
715
- }
716
- discordRPCService.clearPresence(sessionId);
717
- return c.json({ success: true });
718
- });
719
- app.post('/api/discord/heartbeat', async (c) => {
720
- const body = (await c.req.json().catch(() => undefined));
721
- const { sessionId, bye } = body ?? {};
722
- if (typeof sessionId !== 'string' || !discordRPCService.isServiceEnabled) {
723
- return c.json({ success: true });
724
- }
725
- if (bye) {
726
- discordRPCService.removeHeartbeat(sessionId);
727
- }
728
- else {
729
- discordRPCService.heartbeat(sessionId);
730
- }
731
- return c.json({ success: true });
732
- });
733
- }
@@ -62,7 +62,6 @@ async function consolidateFromNumeric(db, numericId) {
62
62
  canonicalId,
63
63
  ]);
64
64
  });
65
- db.scheduleSave();
66
65
  return canonicalId;
67
66
  }
68
67
  if (await WatchlistRepository.exists(db, canonicalId)) {
@@ -72,7 +71,6 @@ async function consolidateFromNumeric(db, numericId) {
72
71
  numericId,
73
72
  canonicalId,
74
73
  ]);
75
- db.scheduleSave();
76
74
  logger.info({ aliasId: numericId, canonicalId }, 'Mapped numeric alias to canonical ID');
77
75
  }
78
76
  return canonicalId;
@@ -133,7 +131,6 @@ async function consolidateFromNumeric(db, numericId) {
133
131
  numericId,
134
132
  ]);
135
133
  });
136
- db.scheduleSave();
137
134
  return numericId;
138
135
  }
139
136
  async function migrateId(db, legacyId) {
@@ -190,7 +187,6 @@ async function migrateId(db, legacyId) {
190
187
  legacyId,
191
188
  ]);
192
189
  });
193
- db.scheduleSave();
194
190
  logger.info({ legacyId, previousId: mapping.numericId, newId: canonicalId }, 'Canonicalized migrated show ID');
195
191
  return canonicalId;
196
192
  }
@@ -278,7 +274,6 @@ async function migrateId(db, legacyId) {
278
274
  ]);
279
275
  tx.run('DELETE FROM discovered_notifications WHERE showId = ?', [legacyId]);
280
276
  });
281
- db.scheduleSave();
282
277
  logger.info({ legacyId, newId, showName }, 'Successfully migrated legacy show ID to numeric ID');
283
278
  return newId;
284
279
  }
@@ -60,7 +60,7 @@ async function readModuleBytes(entryUrl) {
60
60
  return await fs.promises.readFile(filePath);
61
61
  }
62
62
  function resolveEntryUrl(entry, registryUrl) {
63
- const e = entry.trim();
63
+ const e = (entry ?? '').trim();
64
64
  if (isHttp(e) || e.startsWith('file://'))
65
65
  return e;
66
66
  if (isHttp(registryUrl))
@@ -176,6 +176,26 @@ function createCtx(cache) {
176
176
  buildQueryVariants,
177
177
  pickBestMatch,
178
178
  },
179
+ resolveBestShowId: async (title, romaji, searchFn) => {
180
+ const targets = [title, romaji].filter((t) => typeof t === 'string' && t.trim().length > 0);
181
+ if (targets.length === 0)
182
+ return null;
183
+ for (const variant of buildQueryVariants(title, romaji)) {
184
+ let results;
185
+ try {
186
+ results = await searchFn(variant);
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ if (!results || results.length === 0)
192
+ continue;
193
+ const match = pickBestMatch(results.filter((r) => r && typeof r.id === 'string' && r.id.length > 0), targets);
194
+ if (match)
195
+ return match.item.id;
196
+ }
197
+ return null;
198
+ },
179
199
  anilist: {
180
200
  request: (query, vars) => anilistRequest(query, vars),
181
201
  parseMalId,
@@ -277,7 +297,26 @@ function isValidTvProvider(p) {
277
297
  return (typeof o.name === 'string' &&
278
298
  (typeof o.getSources === 'function' || typeof o.getEmbedUrl === 'function'));
279
299
  }
300
+ function makeEmbedProvider(id, base) {
301
+ const root = base.trim().replace(/\/+$/, '');
302
+ return {
303
+ name: id,
304
+ getEmbedUrl: async (media) => {
305
+ const tmdbId = Number(media.tmdbId);
306
+ if (!tmdbId)
307
+ return null;
308
+ if (media.type === 'movie')
309
+ return `${root}/movie/${tmdbId}`;
310
+ return `${root}/tv/${tmdbId}/${media.season || 1}/${media.episode || 1}`;
311
+ },
312
+ };
313
+ }
280
314
  async function loadEntry(item, registryUrl, cacheDir, ctx, allowUnsigned) {
315
+ if (item.embedBase) {
316
+ const provider = makeEmbedProvider(item.id, item.embedBase);
317
+ instanceCache.set(`${item.kind ?? 'anime'}:${item.id}-${item.version}-embed`, { provider });
318
+ return { provider };
319
+ }
281
320
  const entryUrl = resolveEntryUrl(item.entry, registryUrl);
282
321
  await fs.promises.mkdir(cacheDir, { recursive: true });
283
322
  const fileName = `${item.id}-${item.version}.mjs`;
@@ -39,7 +39,13 @@ function parseEntry(raw, index) {
39
39
  if (typeof version !== 'string' || !version.trim()) {
40
40
  throw new Error(`registry.providers[${index}].version must be a non-empty string`);
41
41
  }
42
- if (typeof entry !== 'string' || !entry.trim()) {
42
+ const embedBase = p.embedBase;
43
+ if (embedBase !== undefined) {
44
+ if (typeof embedBase !== 'string' || !/^https:\/\/[^/]+/i.test(embedBase.trim())) {
45
+ throw new Error(`registry.providers[${index}].embedBase must be an https URL`);
46
+ }
47
+ }
48
+ if ((typeof entry !== 'string' || !entry.trim()) && embedBase === undefined) {
43
49
  throw new Error(`registry.providers[${index}].entry must be a non-empty string`);
44
50
  }
45
51
  if (p.sha256 !== undefined && (typeof p.sha256 !== 'string' || !SHA_RE.test(p.sha256.trim()))) {
@@ -78,7 +84,8 @@ function parseEntry(raw, index) {
78
84
  id: id.trim(),
79
85
  label: label.trim(),
80
86
  version: version.trim(),
81
- entry: entry.trim(),
87
+ entry: typeof entry === 'string' ? entry.trim() : undefined,
88
+ embedBase: typeof embedBase === 'string' ? embedBase.trim().replace(/\/+$/, '') : undefined,
82
89
  sha256: typeof p.sha256 === 'string' ? p.sha256.trim().toLowerCase() : undefined,
83
90
  minDango: typeof p.minDango === 'string' ? p.minDango : undefined,
84
91
  mature: typeof p.mature === 'boolean' ? p.mature : false,
@@ -10,9 +10,8 @@ import { serveStatic } from '@hono/node-server/serve-static';
10
10
  import { getConnInfo } from '@hono/node-server/conninfo';
11
11
  import logger from './logger.js';
12
12
  import { notifyServerExit } from './lib/ipc.js';
13
- import { githubSyncService } from './github-sync.js';
14
13
  import { CONFIG } from './config.js';
15
- import { initializeDatabase, initializeMangaDatabase, initializeTvDatabase, initializeAsmrDatabase, syncDownOnBoot, syncUp, mangaSyncDownOnBoot, mangaSyncUp, tvSyncDownOnBoot, tvSyncUp, asmrSyncDownOnBoot, asmrSyncUp, initSyncProvider, waitForSync, getActiveProvider, } from './sync.js';
14
+ import { initializeDatabase, initializeMangaDatabase, initializeTvDatabase, initializeAsmrDatabase, runFullSyncSequence, syncUp, waitForSync, KIND_SYNC, } from './sync.js';
16
15
  import { loadRemoteProviders } from './providers/remote-loader.js';
17
16
  import { discordRPCService } from './discord-rpc.js';
18
17
  import { discordGatewayService } from './discord-gateway.js';
@@ -102,77 +101,8 @@ let asmrDb;
102
101
  let isShuttingDown = false;
103
102
  let bootSyncing = true;
104
103
  async function runSyncSequence(database, mangaDatabase, preferredProvider) {
105
- const dbName = CONFIG.IS_DEV ? CONFIG.DB_NAME_DEV : CONFIG.DB_NAME_PROD;
106
- const dbPath = path.join(CONFIG.ROOT, dbName);
107
104
  const remoteFolder = CONFIG.IS_DEV ? CONFIG.REMOTE_FOLDER_DEV : CONFIG.REMOTE_FOLDER_PROD;
108
- await initSyncProvider(preferredProvider);
109
- if (getActiveProvider() === 'github' && githubSyncService.isAuthenticated()) {
110
- try {
111
- await githubSyncService.migrateFromAniWebSync();
112
- }
113
- catch (err) {
114
- logger.error({ err }, 'GitHub sync migration from ani-web failed');
115
- }
116
- }
117
- const didDownload = await syncDownOnBoot(database, dbPath, remoteFolder, () => {
118
- return new Promise((resolve) => {
119
- if (database && !database.isClosedCheck()) {
120
- database.checkpoint();
121
- database.close(() => resolve());
122
- }
123
- else {
124
- resolve();
125
- }
126
- });
127
- });
128
- let currentDb = database;
129
- if (didDownload) {
130
- db = await initializeDatabase(dbPath);
131
- currentDb = db;
132
- logger.info('Database re-initialized after sync.');
133
- }
134
- try {
135
- await syncUp(currentDb, dbPath, remoteFolder);
136
- }
137
- catch (err) {
138
- logger.error({ err }, 'Sync up on boot failed');
139
- }
140
- try {
141
- await mangaSyncDownOnBoot(mangaDatabase, remoteFolder);
142
- }
143
- catch (err) {
144
- logger.error({ err }, 'Manga sync down on boot failed');
145
- }
146
- try {
147
- await mangaSyncUp(mangaDatabase, remoteFolder);
148
- }
149
- catch (err) {
150
- logger.error({ err }, 'Manga sync up on boot failed');
151
- }
152
- try {
153
- await tvSyncDownOnBoot(tvDb, remoteFolder);
154
- }
155
- catch (err) {
156
- logger.error({ err }, 'TV sync down on boot failed');
157
- }
158
- try {
159
- await tvSyncUp(tvDb, remoteFolder);
160
- }
161
- catch (err) {
162
- logger.error({ err }, 'TV sync up on boot failed');
163
- }
164
- try {
165
- await asmrSyncDownOnBoot(asmrDb, remoteFolder);
166
- }
167
- catch (err) {
168
- logger.error({ err }, 'ASMR sync down on boot failed');
169
- }
170
- try {
171
- await asmrSyncUp(asmrDb, remoteFolder);
172
- }
173
- catch (err) {
174
- logger.error({ err }, 'ASMR sync up on boot failed');
175
- }
105
+ await runFullSyncSequence({ db: database, mangaDb: mangaDatabase, tvDb, asmrDb }, { remoteFolder, preferredProvider });
176
106
  }
177
107
  startWatchlistDiscovery(() => db);
178
108
  if (!CONFIG.IS_DEV) {
@@ -228,42 +158,28 @@ async function main() {
228
158
  refreshRemoteProviders().catch((err) => logger.error({ err }, 'periodic providers refresh failed'));
229
159
  }, CONFIG.PROVIDER_REPO_REFRESH_MS).unref();
230
160
  }
231
- if (!fs.existsSync(CONFIG.LOCAL_MANIFEST_PATH)) {
232
- fs.writeFileSync(CONFIG.LOCAL_MANIFEST_PATH, JSON.stringify({ version: 0 }));
233
- }
234
- if (!fs.existsSync(CONFIG.MANGA_LOCAL_MANIFEST_PATH)) {
235
- fs.writeFileSync(CONFIG.MANGA_LOCAL_MANIFEST_PATH, JSON.stringify({ version: 0 }));
236
- }
237
- if (!fs.existsSync(CONFIG.TV_LOCAL_MANIFEST_PATH)) {
238
- fs.writeFileSync(CONFIG.TV_LOCAL_MANIFEST_PATH, JSON.stringify({ version: 0 }));
239
- }
240
- if (!fs.existsSync(CONFIG.ASMR_LOCAL_MANIFEST_PATH)) {
241
- fs.writeFileSync(CONFIG.ASMR_LOCAL_MANIFEST_PATH, JSON.stringify({ version: 0 }));
242
- }
243
- let hasUnsyncedChanges = false;
244
- let hasMangaUnsyncedChanges = false;
245
- let hasTvUnsyncedChanges = false;
246
- let hasAsmrUnsyncedChanges = false;
247
- const watcher = fs.watch(CONFIG.LOCAL_MANIFEST_PATH, (eventType) => {
248
- if (eventType === 'change' || eventType === 'rename') {
249
- hasUnsyncedChanges = true;
250
- }
161
+ const kindDbs = () => ({
162
+ anime: db,
163
+ manga: mangaDb,
164
+ tv: tvDb,
165
+ asmr: asmrDb,
251
166
  });
252
- const mangaWatcher = fs.watch(CONFIG.MANGA_LOCAL_MANIFEST_PATH, (eventType) => {
253
- if (eventType === 'change' || eventType === 'rename') {
254
- hasMangaUnsyncedChanges = true;
167
+ for (const kind of Object.keys(KIND_SYNC)) {
168
+ if (!fs.existsSync(KIND_SYNC[kind].manifestPath)) {
169
+ fs.writeFileSync(KIND_SYNC[kind].manifestPath, JSON.stringify({ version: 0 }));
255
170
  }
256
- });
257
- const tvWatcher = fs.watch(CONFIG.TV_LOCAL_MANIFEST_PATH, (eventType) => {
258
- if (eventType === 'change' || eventType === 'rename') {
259
- hasTvUnsyncedChanges = true;
260
- }
261
- });
262
- const asmrWatcher = fs.watch(CONFIG.ASMR_LOCAL_MANIFEST_PATH, (eventType) => {
171
+ }
172
+ const unsynced = {
173
+ anime: false,
174
+ manga: false,
175
+ tv: false,
176
+ asmr: false,
177
+ };
178
+ const watchers = Object.keys(KIND_SYNC).map((kind) => fs.watch(KIND_SYNC[kind].manifestPath, (eventType) => {
263
179
  if (eventType === 'change' || eventType === 'rename') {
264
- hasAsmrUnsyncedChanges = true;
180
+ unsynced[kind] = true;
265
181
  }
266
- });
182
+ }));
267
183
  const server = serve({ fetch: app.fetch, port: CONFIG.PORT }, () => {
268
184
  logger.info(`Server running on http://localhost:${CONFIG.PORT}`);
269
185
  });
@@ -273,49 +189,24 @@ async function main() {
273
189
  bootSyncing = false;
274
190
  });
275
191
  refreshRemoteProviders().catch((err) => logger.error({ err }, 'background providers refresh failed'));
192
+ const kindDbLabel = {
193
+ anime: 'database',
194
+ manga: 'manga database',
195
+ tv: 'TV database',
196
+ asmr: 'ASMR database',
197
+ };
276
198
  const syncInterval = setInterval(async () => {
277
- if (hasUnsyncedChanges) {
278
- logger.info('Uploading accumulated database changes...');
279
- hasUnsyncedChanges = false;
280
- try {
281
- await syncUp(db, dbPath, remoteFolder);
282
- }
283
- catch (err) {
284
- logger.error({ err }, 'Failed to upload database changes');
285
- hasUnsyncedChanges = true;
286
- }
287
- }
288
- if (hasMangaUnsyncedChanges) {
289
- logger.info('Uploading accumulated manga database changes...');
290
- hasMangaUnsyncedChanges = false;
291
- try {
292
- await mangaSyncUp(mangaDb, remoteFolder);
293
- }
294
- catch (err) {
295
- logger.error({ err }, 'Failed to upload manga database changes');
296
- hasMangaUnsyncedChanges = true;
297
- }
298
- }
299
- if (hasTvUnsyncedChanges) {
300
- logger.info('Uploading accumulated TV database changes...');
301
- hasTvUnsyncedChanges = false;
199
+ for (const kind of Object.keys(KIND_SYNC)) {
200
+ if (!unsynced[kind])
201
+ continue;
202
+ logger.info(`Uploading accumulated ${kindDbLabel[kind]} changes...`);
203
+ unsynced[kind] = false;
302
204
  try {
303
- await tvSyncUp(tvDb, remoteFolder);
205
+ await syncUp(kindDbs()[kind], kind, remoteFolder);
304
206
  }
305
207
  catch (err) {
306
- logger.error({ err }, 'Failed to upload TV database changes');
307
- hasTvUnsyncedChanges = true;
308
- }
309
- }
310
- if (hasAsmrUnsyncedChanges) {
311
- logger.info('Uploading accumulated ASMR database changes...');
312
- hasAsmrUnsyncedChanges = false;
313
- try {
314
- await asmrSyncUp(asmrDb, remoteFolder);
315
- }
316
- catch (err) {
317
- logger.error({ err }, 'Failed to upload ASMR database changes');
318
- hasAsmrUnsyncedChanges = true;
208
+ logger.error({ err }, `Failed to upload ${kindDbLabel[kind]} changes`);
209
+ unsynced[kind] = true;
319
210
  }
320
211
  }
321
212
  }, 300000);
@@ -337,51 +228,28 @@ async function main() {
337
228
  clearInterval(offlineDbInterval);
338
229
  discordRPCService.disconnect();
339
230
  discordGatewayService.shutdown();
340
- await watcher.close();
341
- await mangaWatcher.close();
342
- await tvWatcher.close();
343
- await asmrWatcher.close();
231
+ for (const watcher of watchers) {
232
+ await watcher.close();
233
+ }
344
234
  if (server) {
345
235
  await new Promise((resolve) => server.close(() => resolve()));
346
236
  }
347
- if (hasUnsyncedChanges) {
348
- logger.info('Sync on shutdown: uploading final database changes...');
349
- hasUnsyncedChanges = false;
350
- try {
351
- await syncUp(db, dbPath, remoteFolder);
352
- }
353
- catch (e) {
354
- logger.error({ err: e }, 'Final sync on shutdown failed');
355
- }
356
- }
357
- if (hasMangaUnsyncedChanges) {
358
- logger.info('Sync on shutdown: uploading final manga database changes...');
359
- hasMangaUnsyncedChanges = false;
360
- try {
361
- await mangaSyncUp(mangaDb, remoteFolder);
362
- }
363
- catch (e) {
364
- logger.error({ err: e }, 'Final manga sync on shutdown failed');
365
- }
366
- }
367
- if (hasTvUnsyncedChanges) {
368
- logger.info('Sync on shutdown: uploading final TV database changes...');
369
- hasTvUnsyncedChanges = false;
370
- try {
371
- await tvSyncUp(tvDb, remoteFolder);
372
- }
373
- catch (e) {
374
- logger.error({ err: e }, 'Final TV sync on shutdown failed');
375
- }
376
- }
377
- if (hasAsmrUnsyncedChanges) {
378
- logger.info('Sync on shutdown: uploading final ASMR database changes...');
379
- hasAsmrUnsyncedChanges = false;
237
+ const kindFinalError = {
238
+ anime: 'Final sync on shutdown failed',
239
+ manga: 'Final manga sync on shutdown failed',
240
+ tv: 'Final TV sync on shutdown failed',
241
+ asmr: 'Final ASMR sync on shutdown failed',
242
+ };
243
+ for (const kind of Object.keys(KIND_SYNC)) {
244
+ if (!unsynced[kind])
245
+ continue;
246
+ logger.info(`Sync on shutdown: uploading final ${kindDbLabel[kind]} changes...`);
247
+ unsynced[kind] = false;
380
248
  try {
381
- await asmrSyncUp(asmrDb, remoteFolder);
249
+ await syncUp(kindDbs()[kind], kind, remoteFolder);
382
250
  }
383
251
  catch (e) {
384
- logger.error({ err: e }, 'Final ASMR sync on shutdown failed');
252
+ logger.error({ err: e }, kindFinalError[kind]);
385
253
  }
386
254
  }
387
255
  await waitForSync();