@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
@@ -0,0 +1,80 @@
1
+ import { protectedProcedure, router } from '../index.js';
2
+ import logger from '../../logger.js';
3
+ import { performWriteTransactionAsync } from '../../sync.js';
4
+ import { WatchlistRepository } from '../../repositories/watchlist.repository.js';
5
+ import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.repository.js';
6
+ import { NotificationsRepository } from '../../repositories/notifications.repository.js';
7
+ import { notificationDismissInput } from '../validation.js';
8
+ function clearAllInput() {
9
+ return {
10
+ '~standard': {
11
+ version: 1,
12
+ vendor: 'dango',
13
+ types: undefined,
14
+ validate(value) {
15
+ try {
16
+ if (value === undefined || value === null)
17
+ return { value: {} };
18
+ if (typeof value !== 'object' || value === null)
19
+ throw new Error('Expected an object');
20
+ const showId = value.showId;
21
+ if (showId === undefined || showId === null)
22
+ return { value: {} };
23
+ if (typeof showId !== 'string' || showId.length === 0)
24
+ throw new Error('showId must be a string');
25
+ return { value: { showId } };
26
+ }
27
+ catch (err) {
28
+ return { issues: [{ message: err instanceof Error ? err.message : 'Invalid input' }] };
29
+ }
30
+ },
31
+ },
32
+ };
33
+ }
34
+ export const notificationsRouter = router({
35
+ list: protectedProcedure.query(async ({ ctx }) => {
36
+ const watchingShows = await WatchlistRepository.getWatchingShows(ctx.db);
37
+ const notifications = [];
38
+ for (const show of watchingShows) {
39
+ try {
40
+ const [watchedEps, dismissedEps, discoveredEps] = await Promise.all([
41
+ WatchedEpisodesRepository.getWatchedEpisodeNumbers(ctx.db, show.id),
42
+ NotificationsRepository.getDismissedByShow(ctx.db, show.id),
43
+ NotificationsRepository.getDiscoveredByShow(ctx.db, show.id),
44
+ ]);
45
+ const watchedSet = new Set(watchedEps.map((e) => e.toString()));
46
+ const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
47
+ for (const discovered of discoveredEps) {
48
+ if (!watchedSet.has(discovered.episodeNumber) &&
49
+ !dismissedSet.has(discovered.episodeNumber)) {
50
+ notifications.push({
51
+ showId: show.id,
52
+ name: show.name,
53
+ nativeName: show.nativeName,
54
+ englishName: show.englishName,
55
+ thumbnail: show.thumbnail,
56
+ episodeNumber: discovered.episodeNumber,
57
+ id: `${show.id}-${discovered.episodeNumber}`,
58
+ });
59
+ }
60
+ }
61
+ }
62
+ catch (e) {
63
+ logger.error({ err: e, showId: show.id }, 'Failed to get notifications for show');
64
+ }
65
+ }
66
+ return notifications.sort((a, b) => parseFloat(b.episodeNumber) - parseFloat(a.episodeNumber));
67
+ }),
68
+ dismiss: protectedProcedure.input(notificationDismissInput()).mutation(async ({ ctx, input }) => {
69
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
70
+ await NotificationsRepository.addDismissed(tx, input.showId, input.episodeNumber);
71
+ });
72
+ return { success: true };
73
+ }),
74
+ clearAll: protectedProcedure.input(clearAllInput()).mutation(async ({ ctx, input }) => {
75
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
76
+ await NotificationsRepository.dismissFromDiscovered(tx, input.showId);
77
+ });
78
+ return { success: true };
79
+ }),
80
+ });
@@ -0,0 +1,15 @@
1
+ import { protectedProcedure, router } from '../index.js';
2
+ import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.repository.js';
3
+ import { getMigratedId } from '../../lib/migration.js';
4
+ import { episodeProgressInput, showIdInput } from '../validation.js';
5
+ export const progressRouter = router({
6
+ getEpisode: protectedProcedure.input(episodeProgressInput()).query(async ({ ctx, input }) => {
7
+ const showId = await getMigratedId(ctx.db, input.showId);
8
+ const progress = await WatchedEpisodesRepository.getByShowAndEpisode(ctx.db, showId, input.episodeNumber);
9
+ return progress || { currentTime: 0, duration: 0 };
10
+ }),
11
+ getWatchedEpisodes: protectedProcedure.input(showIdInput()).query(async ({ ctx, input }) => {
12
+ const showId = await getMigratedId(ctx.db, input.showId);
13
+ return WatchedEpisodesRepository.getWatchedEpisodeNumbers(ctx.db, showId);
14
+ }),
15
+ });
@@ -0,0 +1,95 @@
1
+ import { protectedProcedure, router } from '../index.js';
2
+ import { QueueRepository } from '../../repositories/queue.repository.js';
3
+ import { ShowsMetaRepository } from '../../repositories/shows-meta.repository.js';
4
+ import { performWriteTransactionAsync } from '../../sync.js';
5
+ import { getMigratedId } from '../../lib/migration.js';
6
+ import { queueAddInput, queueBatchInput, queueRemoveInput, queueRemoveManyInput, queueReorderInput, } from '../validation.js';
7
+ function applyMeta(meta) {
8
+ return meta.showName || meta.showThumbnail || meta.nativeName || meta.englishName || meta.type;
9
+ }
10
+ export const queueRouter = router({
11
+ list: protectedProcedure.query(async ({ ctx }) => {
12
+ const rows = await QueueRepository.getAll(ctx.db);
13
+ return rows.map((row) => ({
14
+ ...row,
15
+ _id: row.showId,
16
+ id: row.id,
17
+ thumbnail: row.thumbnail || '',
18
+ }));
19
+ }),
20
+ add: protectedProcedure.input(queueAddInput()).mutation(async ({ ctx, input }) => {
21
+ const showId = await getMigratedId(ctx.db, input.showId);
22
+ const episodeNumber = input.episodeNumber;
23
+ const existing = await QueueRepository.getByEpisode(ctx.db, showId, episodeNumber);
24
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
25
+ if (applyMeta(input)) {
26
+ await ShowsMetaRepository.upsert(tx, {
27
+ id: showId,
28
+ name: input.showName || '',
29
+ thumbnail: input.showThumbnail || '',
30
+ nativeName: input.nativeName,
31
+ englishName: input.englishName,
32
+ type: input.type,
33
+ });
34
+ }
35
+ if (existing) {
36
+ await QueueRepository.removeEpisode(tx, showId, episodeNumber);
37
+ }
38
+ else {
39
+ await QueueRepository.addToEnd(tx, showId, episodeNumber);
40
+ }
41
+ });
42
+ ctx.db.scheduleSave();
43
+ return { success: true, queued: !existing };
44
+ }),
45
+ addBatch: protectedProcedure.input(queueBatchInput()).mutation(async ({ ctx, input }) => {
46
+ const showId = await getMigratedId(ctx.db, input.showId);
47
+ const normalized = [...new Set(input.episodeNumbers)];
48
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
49
+ if (applyMeta(input)) {
50
+ await ShowsMetaRepository.upsert(tx, {
51
+ id: showId,
52
+ name: input.showName || '',
53
+ thumbnail: input.showThumbnail || '',
54
+ nativeName: input.nativeName,
55
+ englishName: input.englishName,
56
+ type: input.type,
57
+ });
58
+ }
59
+ await QueueRepository.addManyToEnd(tx, normalized.map((episodeNumber) => ({ showId, episodeNumber })));
60
+ });
61
+ ctx.db.scheduleSave();
62
+ return { success: true, added: normalized.length };
63
+ }),
64
+ remove: protectedProcedure.input(queueRemoveInput()).mutation(async ({ ctx, input }) => {
65
+ const showId = await getMigratedId(ctx.db, input.showId);
66
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
67
+ await QueueRepository.removeEpisode(tx, showId, input.episodeNumber);
68
+ });
69
+ return { success: true };
70
+ }),
71
+ removeMany: protectedProcedure.input(queueRemoveManyInput()).mutation(async ({ ctx, input }) => {
72
+ const showId = await getMigratedId(ctx.db, input.showId);
73
+ let removed;
74
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
75
+ removed = ((await QueueRepository.getByShow(tx, showId)) || []).map((ep) => ep.episodeNumber);
76
+ const toRemove = Array.isArray(input.episodeNumbers) && input.episodeNumbers.length
77
+ ? [...new Set(input.episodeNumbers)]
78
+ : removed;
79
+ await QueueRepository.removeMany(tx, showId, toRemove);
80
+ });
81
+ return { success: true, removed: removed.length };
82
+ }),
83
+ clear: protectedProcedure.mutation(async ({ ctx }) => {
84
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
85
+ await QueueRepository.clear(tx);
86
+ });
87
+ return { success: true };
88
+ }),
89
+ reorder: protectedProcedure.input(queueReorderInput()).mutation(async ({ ctx, input }) => {
90
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
91
+ await QueueRepository.reorder(tx, input);
92
+ });
93
+ return { success: true };
94
+ }),
95
+ });
@@ -1,6 +1,7 @@
1
- import { Router } from 'express';
2
- import logger from '../logger.js';
3
- import { parseJsonBody } from '../utils/http.utils.js';
1
+ import { protectedProcedure, router } from '../index.js';
2
+ import { defineSchema, optStr, reqObj } from '../validation.js';
3
+ import logger from '../../logger.js';
4
+ import { parseJsonBody } from '../../utils/http.utils.js';
4
5
  const LISTEN_MOE_STATIONS = [
5
6
  {
6
7
  id: 'listenmoe-jpop',
@@ -96,43 +97,49 @@ function toStation(s) {
96
97
  source: 'radio-browser',
97
98
  };
98
99
  }
99
- export function createRadioRouter(apiCache) {
100
- const router = Router();
101
- router.get('/radio/stations', async (_req, res) => {
100
+ const radioSearchInput = () => defineSchema((value) => {
101
+ const obj = reqObj(value);
102
+ const out = {};
103
+ const q = optStr(obj, 'q');
104
+ if (q !== undefined)
105
+ out.q = q;
106
+ return out;
107
+ });
108
+ export const radioRouter = router({
109
+ stations: protectedProcedure.query(async ({ ctx }) => {
102
110
  try {
103
- const cached = apiCache.get('route-radio-stations');
111
+ const cached = ctx.apiCache.get('route-radio-stations');
104
112
  if (cached)
105
- return res.json({ stations: cached });
113
+ return { stations: cached };
106
114
  const stations = [...LISTEN_MOE_STATIONS, ...FEATURED_STATIONS];
107
- apiCache.set('route-radio-stations', stations, 3600);
108
- res.json({ stations });
115
+ ctx.apiCache.set('route-radio-stations', stations, 3600);
116
+ return { stations };
109
117
  }
110
118
  catch (err) {
111
119
  logger.error({ err }, '[Radio] stations failed');
112
- res.json({ stations: LISTEN_MOE_STATIONS });
120
+ return { stations: LISTEN_MOE_STATIONS };
113
121
  }
114
- });
115
- router.get('/radio/search', async (req, res) => {
122
+ }),
123
+ search: protectedProcedure.input(radioSearchInput()).query(async ({ ctx, input }) => {
116
124
  try {
117
- const q = String(req.query.q || '').trim();
125
+ const q = String(input.q || '').trim();
118
126
  if (!q)
119
- return res.json({ stations: [] });
127
+ return { stations: [] };
120
128
  const cacheKey = `route-radio-search-${q.toLowerCase()}`;
121
- const cached = apiCache.get(cacheKey);
129
+ const cached = ctx.apiCache.get(cacheKey);
122
130
  if (cached)
123
- return res.json({ stations: cached });
131
+ return { stations: cached };
124
132
  const raw = (await rbFetch(`/json/stations/search?name=${encodeURIComponent(q)}&hidebroken=true&order=clickcount&reverse=true&limit=25`));
125
133
  const stations = raw
126
134
  .map(toStation)
127
135
  .filter((s) => s !== null)
128
136
  .slice(0, 25);
129
- apiCache.set(cacheKey, stations, 300);
130
- res.json({ stations });
137
+ ctx.apiCache.set(cacheKey, stations, 300);
138
+ return { stations };
131
139
  }
132
140
  catch (err) {
133
141
  logger.error({ err }, '[Radio] search failed');
134
- res.json({ stations: [] });
142
+ return { stations: [] };
135
143
  }
136
- });
137
- return router;
138
- }
144
+ }),
145
+ });
@@ -0,0 +1,105 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { SettingsRepository } from '../../repositories/settings.repository.js';
4
+ import { performWriteTransactionAsync } from '../../sync.js';
5
+ import { discordRPCService } from '../../discord-rpc.js';
6
+ import logger from '../../logger.js';
7
+ import { offlineDb } from '../../lib/offline-db.js';
8
+ import { translateTexts } from '../../lib/translate.js';
9
+ import { getMachineId } from '../../utils/machine-id.js';
10
+ import { getMalImportStatus, requestMalImportCancel } from '../../lib/mal-import.js';
11
+ import { defineSchema, reqObj } from '../validation.js';
12
+ import { keyInput, setSettingInput } from '../validation.js';
13
+ export const SETTING_DEFAULTS = {
14
+ discordRPCEnabled: 'true',
15
+ discordRPCHideMature: 'true',
16
+ ignoreAdultContent: 'true',
17
+ mangaIgnoreAdultContent: 'true',
18
+ tvIgnoreAdultContent: 'true',
19
+ asmrIgnoreAdultContent: 'true',
20
+ };
21
+ const autoUpdateInput = () => defineSchema((value) => {
22
+ const obj = reqObj(value);
23
+ if (typeof obj.enabled !== 'boolean')
24
+ throw new Error('enabled must be a boolean');
25
+ return { enabled: obj.enabled };
26
+ });
27
+ const translateInput = () => defineSchema((value) => {
28
+ if (value === undefined || value === null)
29
+ return {};
30
+ const obj = reqObj(value);
31
+ return { texts: obj.texts, source: obj.source, target: obj.target };
32
+ });
33
+ export const settingsRouter = router({
34
+ getByKey: protectedProcedure.input(keyInput()).query(async ({ ctx, input }) => {
35
+ const row = await SettingsRepository.getByKey(ctx.db, input.key);
36
+ const value = row ? row.value : (SETTING_DEFAULTS[input.key] ?? null);
37
+ return { value };
38
+ }),
39
+ set: protectedProcedure.input(setSettingInput()).mutation(async ({ ctx, input }) => {
40
+ const key = input.key;
41
+ const value = String(input.value ?? '');
42
+ const shouldDelete = value === '' && key === 'tracker_anilist_client_id';
43
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
44
+ if (shouldDelete)
45
+ await SettingsRepository.deleteByKey(tx, key);
46
+ else
47
+ await SettingsRepository.upsert(tx, key, value);
48
+ });
49
+ if (key === 'discordRPCEnabled') {
50
+ discordRPCService.setEnabled(input.value === 'true' || input.value === true);
51
+ }
52
+ if (key === 'discordRPCHideMature') {
53
+ discordRPCService.setHideMature(input.value === 'true' || input.value === true);
54
+ }
55
+ return { success: true };
56
+ }),
57
+ offlineDbInfo: protectedProcedure.query(async ({ ctx }) => {
58
+ return await offlineDb.getOfflineDbInfo(ctx.db);
59
+ }),
60
+ setOfflineDbAutoUpdate: protectedProcedure
61
+ .input(autoUpdateInput())
62
+ .mutation(async ({ ctx, input }) => {
63
+ await SettingsRepository.upsert(ctx.db, 'offlineDbAutoUpdateEnabled', String(input.enabled));
64
+ return { success: true, enabled: input.enabled };
65
+ }),
66
+ triggerOfflineDbUpdate: protectedProcedure.mutation(async ({ ctx }) => {
67
+ const info = await offlineDb.getOfflineDbInfo(ctx.db);
68
+ if (info.isRefreshing) {
69
+ throw new TRPCError({
70
+ code: 'CONFLICT',
71
+ message: 'Offline database refresh already in progress',
72
+ });
73
+ }
74
+ offlineDb.refreshDatabase(ctx.db).catch((err) => {
75
+ logger.warn({ err: err?.message }, 'Manual offline database update failed');
76
+ });
77
+ return { message: 'Offline database refresh started' };
78
+ }),
79
+ installationId: protectedProcedure.query(() => {
80
+ try {
81
+ return { id: getMachineId() };
82
+ }
83
+ catch (err) {
84
+ logger.error({ err }, 'Failed to get machine ID');
85
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to get machine ID' });
86
+ }
87
+ }),
88
+ translate: protectedProcedure.input(translateInput()).mutation(async ({ input }) => {
89
+ const texts = input.texts;
90
+ const source = input.source ?? 'ja';
91
+ const target = input.target ?? 'en';
92
+ if (!Array.isArray(texts) || texts.length === 0) {
93
+ return { translations: {} };
94
+ }
95
+ return {
96
+ translations: await translateTexts(texts, String(source), String(target)),
97
+ };
98
+ }),
99
+ malImportStatus: protectedProcedure.query(() => {
100
+ return getMalImportStatus();
101
+ }),
102
+ malImportCancel: protectedProcedure.mutation(() => {
103
+ return requestMalImportCancel();
104
+ }),
105
+ });
@@ -0,0 +1,219 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { SettingsRepository } from '../../repositories/settings.repository.js';
4
+ import { performWriteTransactionAsync } from '../../sync.js';
5
+ import { AniListTracker } from '../../lib/tracker/anilist-tracker.js';
6
+ import { syncAniList, importFromMalUsername, importFromUsername, } from '../../lib/tracker/sync.service.js';
7
+ import { syncAniListManga, importFromUsernameManga, importMangaFromMalUsername, } from '../../lib/tracker/manga-sync.service.js';
8
+ import { defineSchema, reqObj } from '../validation.js';
9
+ const TRACKER_TOKEN_KEY = 'tracker_anilist_token';
10
+ const TRACKER_USER_KEY = 'tracker_anilist_user';
11
+ function badRequest(message) {
12
+ return new TRPCError({ code: 'BAD_REQUEST', message });
13
+ }
14
+ function failed(message) {
15
+ return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message });
16
+ }
17
+ function unavailable(message) {
18
+ return new TRPCError({ code: 'SERVICE_UNAVAILABLE', message });
19
+ }
20
+ function optBody(value) {
21
+ if (value === undefined || value === null)
22
+ return {};
23
+ return reqObj(value);
24
+ }
25
+ const trackerTokenInput = () => defineSchema((value) => {
26
+ const obj = optBody(value);
27
+ return { token: obj.token, code: obj.code, redirectUri: obj.redirectUri };
28
+ });
29
+ const trackerSyncInput = () => defineSchema((value) => ({
30
+ provider: optBody(value).provider,
31
+ }));
32
+ const trackerUsernameInput = () => defineSchema((value) => {
33
+ const obj = optBody(value);
34
+ return { username: obj.username, erase: obj.erase };
35
+ });
36
+ const trackerMalInput = () => defineSchema((value) => {
37
+ const obj = optBody(value);
38
+ return {
39
+ username: obj.username,
40
+ erase: obj.erase,
41
+ useOfflineDb: obj.useOfflineDb,
42
+ skipFallback: obj.skipFallback,
43
+ };
44
+ });
45
+ const trackerMangaSyncInput = () => defineSchema((value) => ({
46
+ direction: optBody(value).direction,
47
+ }));
48
+ function mapImportError(message) {
49
+ if (message.includes('private') || message.includes('not found')) {
50
+ return new TRPCError({ code: 'NOT_FOUND', message });
51
+ }
52
+ if (message.includes('blocked') || message.includes('HTTP 429')) {
53
+ return new TRPCError({ code: 'TOO_MANY_REQUESTS', message });
54
+ }
55
+ return failed(message);
56
+ }
57
+ export const trackerRouter = router({
58
+ status: protectedProcedure.query(async ({ ctx }) => {
59
+ try {
60
+ const tokenRow = await SettingsRepository.getByKey(ctx.db, TRACKER_TOKEN_KEY);
61
+ const userRow = await SettingsRepository.getByKey(ctx.db, TRACKER_USER_KEY);
62
+ let user = null;
63
+ if (userRow?.value) {
64
+ try {
65
+ user = JSON.parse(userRow.value);
66
+ }
67
+ catch {
68
+ user = null;
69
+ }
70
+ }
71
+ return { anilist: { connected: !!tokenRow?.value, user } };
72
+ }
73
+ catch {
74
+ throw failed('Failed to read tracker status');
75
+ }
76
+ }),
77
+ anilistAuth: protectedProcedure.input(trackerTokenInput()).mutation(async ({ ctx, input }) => {
78
+ const accessToken = typeof input.token === 'string' ? input.token.trim() : '';
79
+ if (!accessToken) {
80
+ throw badRequest('Access token is required');
81
+ }
82
+ try {
83
+ const tracker = new AniListTracker(accessToken);
84
+ const viewer = await tracker.getViewer();
85
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
86
+ await SettingsRepository.upsert(tx, TRACKER_TOKEN_KEY, accessToken);
87
+ await SettingsRepository.upsert(tx, TRACKER_USER_KEY, JSON.stringify(viewer));
88
+ });
89
+ return { success: true, user: viewer };
90
+ }
91
+ catch (err) {
92
+ throw new TRPCError({
93
+ code: 'UNAUTHORIZED',
94
+ message: err instanceof Error ? err.message : 'Authentication failed',
95
+ });
96
+ }
97
+ }),
98
+ anilistDisconnect: protectedProcedure.mutation(async ({ ctx }) => {
99
+ try {
100
+ await performWriteTransactionAsync(ctx.db, async (tx) => {
101
+ await SettingsRepository.upsert(tx, TRACKER_TOKEN_KEY, '');
102
+ await SettingsRepository.upsert(tx, TRACKER_USER_KEY, '');
103
+ });
104
+ return { success: true };
105
+ }
106
+ catch {
107
+ throw failed('Failed to disconnect');
108
+ }
109
+ }),
110
+ sync: protectedProcedure.input(trackerSyncInput()).mutation(async ({ ctx, input }) => {
111
+ const { provider = 'anilist' } = input;
112
+ if (provider !== 'anilist') {
113
+ throw badRequest(`Provider "${provider}" is not supported yet`);
114
+ }
115
+ try {
116
+ const summary = await syncAniList(ctx.db);
117
+ return { success: true, summary };
118
+ }
119
+ catch (err) {
120
+ throw failed(err instanceof Error ? err.message : 'Sync failed');
121
+ }
122
+ }),
123
+ anilistImport: protectedProcedure
124
+ .input(trackerUsernameInput())
125
+ .mutation(async ({ ctx, input }) => {
126
+ const { username, erase } = input;
127
+ if (!username || typeof username !== 'string') {
128
+ throw badRequest('Username is required');
129
+ }
130
+ try {
131
+ const count = await importFromUsername(ctx.db, username.trim(), erase === true);
132
+ return { success: true, count };
133
+ }
134
+ catch (err) {
135
+ throw failed(err instanceof Error ? err.message : 'Import failed');
136
+ }
137
+ }),
138
+ malImport: protectedProcedure.input(trackerMalInput()).mutation(async ({ ctx, input }) => {
139
+ const { username, erase, useOfflineDb, skipFallback } = input;
140
+ if (!username || typeof username !== 'string') {
141
+ throw badRequest('MAL username is required');
142
+ }
143
+ try {
144
+ const count = await importFromMalUsername(ctx.db, username.trim(), {
145
+ erase: erase === true,
146
+ useOfflineDb: useOfflineDb !== false,
147
+ skipFallback: skipFallback === true,
148
+ });
149
+ return { success: true, count };
150
+ }
151
+ catch (err) {
152
+ throw mapImportError(err instanceof Error ? err.message : 'Import failed');
153
+ }
154
+ }),
155
+ mangaSync: protectedProcedure.input(trackerMangaSyncInput()).mutation(async ({ ctx, input }) => {
156
+ const { direction } = input;
157
+ try {
158
+ const mangaDb = ctx.mangaDb;
159
+ if (!mangaDb || mangaDb.isClosedCheck()) {
160
+ throw unavailable('Manga database is not ready');
161
+ }
162
+ const syncDirection = direction === 'pull-only' ? 'pull-only' : 'two-way';
163
+ const result = await syncAniListManga(ctx.db, mangaDb, {
164
+ direction: syncDirection,
165
+ });
166
+ return {
167
+ success: true,
168
+ summary: result.summary,
169
+ details: result.details,
170
+ direction: syncDirection,
171
+ };
172
+ }
173
+ catch (err) {
174
+ if (err instanceof TRPCError)
175
+ throw err;
176
+ throw failed(err instanceof Error ? err.message : 'Manga sync failed');
177
+ }
178
+ }),
179
+ mangaImport: protectedProcedure.input(trackerUsernameInput()).mutation(async ({ ctx, input }) => {
180
+ const { username, erase } = input;
181
+ if (!username || typeof username !== 'string') {
182
+ throw badRequest('Username is required');
183
+ }
184
+ try {
185
+ const mangaDb = ctx.mangaDb;
186
+ if (!mangaDb || mangaDb.isClosedCheck()) {
187
+ throw unavailable('Manga database is not ready');
188
+ }
189
+ const count = await importFromUsernameManga(ctx.db, mangaDb, username.trim(), erase === true);
190
+ return { success: true, count };
191
+ }
192
+ catch (err) {
193
+ if (err instanceof TRPCError)
194
+ throw err;
195
+ throw failed(err instanceof Error ? err.message : 'Import failed');
196
+ }
197
+ }),
198
+ mangaMalImport: protectedProcedure
199
+ .input(trackerUsernameInput())
200
+ .mutation(async ({ ctx, input }) => {
201
+ const { username, erase } = input;
202
+ if (!username || typeof username !== 'string') {
203
+ throw badRequest('Username is required');
204
+ }
205
+ try {
206
+ const mangaDb = ctx.mangaDb;
207
+ if (!mangaDb || mangaDb.isClosedCheck()) {
208
+ throw unavailable('Manga database is not ready');
209
+ }
210
+ const result = await importMangaFromMalUsername(ctx.db, mangaDb, username.trim(), erase === true);
211
+ return { success: true, ...result };
212
+ }
213
+ catch (err) {
214
+ if (err instanceof TRPCError)
215
+ throw err;
216
+ throw mapImportError(err instanceof Error ? err.message : 'Import failed');
217
+ }
218
+ }),
219
+ });