@serifpersia/dango 3.3.9 → 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-URCiiy5W.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.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-B0qWqhb_.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.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-B7f1-e6i.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.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-BxfgjP4i.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-BWDwWnHe.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-BO_L1WoN.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-D_o9-QlR.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-TFzkC-tG.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 +0 -2
  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} +155 -288
  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-C28TfSvX.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-6Xh71AgR.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-BwCtRW0P.js +0 -10
  128. package/client/dist/assets/Home-BJdbHYja.js +0 -1
  129. package/client/dist/assets/Insights-CjRy42YE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-YCETZRte.js +0 -1
  131. package/client/dist/assets/Mature-BGxRWkV5.js +0 -1
  132. package/client/dist/assets/MediaCard-CAzoxsRS.js +0 -1
  133. package/client/dist/assets/Music-Cgo-yQ7E.js +0 -1
  134. package/client/dist/assets/OptionListMenu--Owe9Jle.js +0 -1
  135. package/client/dist/assets/Player-yRXG2BrP.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DrZLa0CC.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-BVZyPyKf.js +0 -1
  138. package/client/dist/assets/Radio-BFjxlzuF.js +0 -1
  139. package/client/dist/assets/Search-BOBo0Ogh.js +0 -1
  140. package/client/dist/assets/Settings-DOvNQ0gX.js +0 -21
  141. package/client/dist/assets/Trackers-DZ9KqOWv.js +0 -2
  142. package/client/dist/assets/Tv-5mATEU4p.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-CF-5C5Fs.js +0 -1
  145. package/client/dist/assets/TvPopup-Dfqifj3s.js +0 -1
  146. package/client/dist/assets/Watchlist-B8MHNsAU.js +0 -1
  147. package/client/dist/assets/index-D2RkUJIN.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-BWShDHqE.js +0 -1
  149. package/client/dist/assets/useAsmr-FyfD9G7b.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +0 -1
  151. package/client/dist/assets/useProviders-DDRxa8vl.js +0 -1
  152. package/client/dist/assets/useTv-Bh6WmNnZ.js +0 -1
  153. package/client/dist/assets/useTvLibrary-_Pqm8re1.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,288 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { defineSchema, optStr, reqObj } from '../validation.js';
4
+ import { discordRPCService } from '../../discord-rpc.js';
5
+ import { discordGatewayService } from '../../discord-gateway.js';
6
+ import { getDlsitePoster, getMangadexCover } from '../../hono/watchlist.js';
7
+ import { updateEnvFile } from '../../utils/env.utils.js';
8
+ import { CONFIG } from '../../config.js';
9
+ import logger from '../../logger.js';
10
+ function failed(message) {
11
+ return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message });
12
+ }
13
+ function badRequest(message) {
14
+ return new TRPCError({ code: 'BAD_REQUEST', message });
15
+ }
16
+ function maskToken(t) {
17
+ if (!t)
18
+ return 'none';
19
+ return t.slice(0, 8) + '...' + t.slice(-4);
20
+ }
21
+ const gatewayLog = logger.child({ module: 'DiscordGatewayRoutes' });
22
+ function optUnknown(obj, name) {
23
+ return obj[name];
24
+ }
25
+ function cleanProxyThumb(value) {
26
+ let thumb = String(value || '');
27
+ if (thumb.includes('/api/proxy') || thumb.includes('/api/image-proxy')) {
28
+ const match = thumb.match(/url=([^&]+)/);
29
+ if (match) {
30
+ try {
31
+ thumb = decodeURIComponent(match[1]);
32
+ }
33
+ catch {
34
+ return '';
35
+ }
36
+ }
37
+ }
38
+ if (!thumb.startsWith('https://'))
39
+ return '';
40
+ if (thumb.includes('localhost') || thumb.includes('127.0.0.1'))
41
+ return '';
42
+ return thumb;
43
+ }
44
+ const presenceInput = () => defineSchema((value) => {
45
+ if (value === undefined || value === null)
46
+ return {};
47
+ return { ...reqObj(value) };
48
+ });
49
+ export const discordRouter = router({
50
+ pageStatus: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
51
+ const page = optUnknown(input, 'page');
52
+ if (typeof page !== 'string' || !discordRPCService.isServiceEnabled) {
53
+ return { success: true };
54
+ }
55
+ discordRPCService.setIdleStatus(page);
56
+ return { success: true };
57
+ }),
58
+ heartbeat: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
59
+ const sessionId = optUnknown(input, 'sessionId');
60
+ const bye = optUnknown(input, 'bye');
61
+ if (typeof sessionId !== 'string' || !discordRPCService.isServiceEnabled) {
62
+ return { success: true };
63
+ }
64
+ if (bye) {
65
+ discordRPCService.removeHeartbeat(sessionId);
66
+ }
67
+ else {
68
+ discordRPCService.heartbeat(sessionId);
69
+ }
70
+ return { success: true };
71
+ }),
72
+ asmrPresence: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
73
+ if (!discordRPCService.isServiceEnabled)
74
+ return { success: true };
75
+ const sessionId = optUnknown(input, 'sessionId');
76
+ if (typeof sessionId === 'string')
77
+ discordRPCService.heartbeat(sessionId);
78
+ const thumbnail = optUnknown(input, 'thumbnail');
79
+ const thumbnails = optUnknown(input, 'thumbnails');
80
+ const rjCode = optUnknown(input, 'rjCode');
81
+ let thumb = String(thumbnail || '');
82
+ const thumbs = Array.isArray(thumbnails) ? thumbnails.map(String).filter(Boolean) : undefined;
83
+ const needsDlsite = !thumb || thumb.includes('weeabo0.xyz') || thumb.includes('japaneseasmr.com');
84
+ if (needsDlsite && rjCode) {
85
+ const dlsiteThumb = await getDlsitePoster(String(rjCode));
86
+ if (dlsiteThumb)
87
+ thumb = dlsiteThumb;
88
+ }
89
+ if (thumb.includes('/api/image-proxy') || thumb.includes('weeabo0.xyz'))
90
+ thumb = '';
91
+ discordRPCService.updatePresence({
92
+ title: String(optUnknown(input, 'title') || 'ASMR').slice(0, 128),
93
+ episode: String(optUnknown(input, 'trackLabel') || '').slice(0, 64),
94
+ totalEpisodes: '',
95
+ currentTime: Number(optUnknown(input, 'currentTime')) || 0,
96
+ duration: Number(optUnknown(input, 'duration')) || 0,
97
+ thumbnail: thumb,
98
+ thumbnails: thumbs,
99
+ isPlaying: !!optUnknown(input, 'isPlaying'),
100
+ providerName: 'ASMR',
101
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
102
+ isAdult: !!optUnknown(input, 'isAdult'),
103
+ });
104
+ return { success: true };
105
+ }),
106
+ radioPresence: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
107
+ if (!discordRPCService.isServiceEnabled)
108
+ return { success: true };
109
+ const sessionId = optUnknown(input, 'sessionId');
110
+ if (typeof sessionId === 'string')
111
+ discordRPCService.heartbeat(sessionId);
112
+ let thumb = String(optUnknown(input, 'thumbnail') || '');
113
+ if (thumb.includes('/api/image-proxy')) {
114
+ const match = thumb.match(/url=([^&]+)/);
115
+ if (match)
116
+ thumb = decodeURIComponent(match[1]);
117
+ }
118
+ if (thumb && !thumb.startsWith('https://'))
119
+ thumb = '';
120
+ if (thumb.includes('localhost') || thumb.includes('127.0.0.1'))
121
+ thumb = '';
122
+ const stationLabel = String(optUnknown(input, 'stationLabel') || '');
123
+ discordRPCService.updatePresence({
124
+ title: String(optUnknown(input, 'title') || 'Radio').slice(0, 128),
125
+ episode: stationLabel.slice(0, 64),
126
+ totalEpisodes: '',
127
+ stateLine: (stationLabel || 'Radio').slice(0, 64),
128
+ currentTime: Number(optUnknown(input, 'currentTime')) || 0,
129
+ duration: 0,
130
+ thumbnail: thumb,
131
+ isPlaying: !!optUnknown(input, 'isPlaying'),
132
+ providerName: 'Radio',
133
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
134
+ isAdult: false,
135
+ });
136
+ return { success: true };
137
+ }),
138
+ musicPresence: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
139
+ if (!discordRPCService.isServiceEnabled)
140
+ return { success: true };
141
+ const sessionId = optUnknown(input, 'sessionId');
142
+ if (typeof sessionId === 'string')
143
+ discordRPCService.heartbeat(sessionId);
144
+ let thumb = String(optUnknown(input, 'thumbnail') || '');
145
+ if (thumb.includes('/api/image-proxy')) {
146
+ const match = thumb.match(/url=([^&]+)/);
147
+ if (match)
148
+ thumb = decodeURIComponent(match[1]);
149
+ }
150
+ if (thumb && !thumb.startsWith('https://'))
151
+ thumb = '';
152
+ if (thumb.includes('localhost') || thumb.includes('127.0.0.1'))
153
+ thumb = '';
154
+ const artistLabel = String(optUnknown(input, 'artistLabel') || '');
155
+ discordRPCService.updatePresence({
156
+ title: String(optUnknown(input, 'title') || 'Music').slice(0, 128),
157
+ episode: artistLabel.slice(0, 64),
158
+ totalEpisodes: '',
159
+ stateLine: (artistLabel || 'Music').slice(0, 64),
160
+ currentTime: Number(optUnknown(input, 'currentTime')) || 0,
161
+ duration: Number(optUnknown(input, 'duration')) || 0,
162
+ thumbnail: thumb,
163
+ isPlaying: !!optUnknown(input, 'isPlaying'),
164
+ providerName: 'Music',
165
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
166
+ isAdult: false,
167
+ });
168
+ return { success: true };
169
+ }),
170
+ mangaPresence: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
171
+ if (!discordRPCService.isServiceEnabled)
172
+ return { success: true };
173
+ const sessionId = optUnknown(input, 'sessionId');
174
+ if (typeof sessionId === 'string')
175
+ discordRPCService.heartbeat(sessionId);
176
+ const thumbnails = optUnknown(input, 'thumbnails');
177
+ const title = optUnknown(input, 'title');
178
+ let thumb = cleanProxyThumb(optUnknown(input, 'thumbnail'));
179
+ let thumbs = Array.isArray(thumbnails)
180
+ ? thumbnails.map(cleanProxyThumb).filter(Boolean)
181
+ : undefined;
182
+ if (!thumb && title) {
183
+ const fallback = await getMangadexCover(String(title));
184
+ if (fallback) {
185
+ thumb = fallback;
186
+ thumbs = [fallback, ...(thumbs || [])];
187
+ }
188
+ }
189
+ const chapterLabel = String(optUnknown(input, 'chapterLabel') || '');
190
+ discordRPCService.updatePresence({
191
+ title: String(title || 'Manga').slice(0, 128),
192
+ episode: chapterLabel.slice(0, 64),
193
+ totalEpisodes: '',
194
+ stateLine: (chapterLabel ? `Reading ${chapterLabel}` : 'Reading').slice(0, 64),
195
+ currentTime: 0,
196
+ duration: 0,
197
+ thumbnail: thumb,
198
+ thumbnails: thumbs,
199
+ isPlaying: optUnknown(input, 'isPlaying') !== false,
200
+ providerName: 'Manga',
201
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
202
+ isAdult: optUnknown(input, 'isAdult') === true,
203
+ });
204
+ return { success: true };
205
+ }),
206
+ tvPresence: protectedProcedure.input(presenceInput()).mutation(async ({ input }) => {
207
+ if (!discordRPCService.isServiceEnabled)
208
+ return { success: true };
209
+ const sessionId = optUnknown(input, 'sessionId');
210
+ if (typeof sessionId === 'string')
211
+ discordRPCService.heartbeat(sessionId);
212
+ const thumbnail = optUnknown(input, 'thumbnail');
213
+ let thumb = String(thumbnail || '');
214
+ if (thumb.includes('/api/image-proxy')) {
215
+ const match = thumb.match(/url=([^&]+)/);
216
+ if (match)
217
+ thumb = decodeURIComponent(match[1]);
218
+ }
219
+ if (thumb.includes('localhost') || thumb.includes('127.0.0.1'))
220
+ thumb = '';
221
+ const episodeLabel = String(optUnknown(input, 'episodeLabel') || '');
222
+ discordRPCService.updatePresence({
223
+ title: String(optUnknown(input, 'title') || 'TV').slice(0, 128),
224
+ episode: episodeLabel.slice(0, 64),
225
+ totalEpisodes: '',
226
+ stateLine: (episodeLabel || 'Movie').slice(0, 64),
227
+ currentTime: Number(optUnknown(input, 'currentTime')) || 0,
228
+ duration: Number(optUnknown(input, 'duration')) || 0,
229
+ thumbnail: thumb,
230
+ isPlaying: !!optUnknown(input, 'isPlaying'),
231
+ providerName: 'TV',
232
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
233
+ isAdult: optUnknown(input, 'isAdult') === true,
234
+ });
235
+ return { success: true };
236
+ }),
237
+ gatewayStatus: protectedProcedure.query(() => {
238
+ const hasToken = discordGatewayService.hasToken();
239
+ return {
240
+ hasToken,
241
+ masked: hasToken ? maskToken(process.env.DISCORD_GATEWAY_TOKEN) : null,
242
+ enabled: discordGatewayService.serviceEnabled,
243
+ };
244
+ }),
245
+ gatewaySave: protectedProcedure
246
+ .input(defineSchema((value) => {
247
+ if (value === undefined || value === null)
248
+ return {};
249
+ const obj = reqObj(value);
250
+ const out = {};
251
+ const token = optStr(obj, 'token');
252
+ if (token !== undefined)
253
+ out.token = token;
254
+ return out;
255
+ }))
256
+ .mutation(async ({ input }) => {
257
+ let token = (input.token || '').trim();
258
+ if (token.startsWith('"') && token.endsWith('"'))
259
+ token = token.slice(1, -1);
260
+ if (token.length < 30)
261
+ throw badRequest('Token too short');
262
+ try {
263
+ await updateEnvFile({ DISCORD_GATEWAY_TOKEN: token });
264
+ discordGatewayService.reloadToken();
265
+ gatewayLog.info(`Discord Gateway token saved ${maskToken(token)} (not logged)`);
266
+ return { ok: true, masked: maskToken(token) };
267
+ }
268
+ catch (e) {
269
+ gatewayLog.error({ err: e }, 'Failed to save Discord Gateway token');
270
+ throw failed('Failed to save token');
271
+ }
272
+ }),
273
+ gatewayRemove: protectedProcedure.mutation(async () => {
274
+ try {
275
+ await updateEnvFile({ DISCORD_GATEWAY_TOKEN: '' });
276
+ discordGatewayService.reloadToken();
277
+ gatewayLog.info('Discord Gateway token removed');
278
+ return { ok: true };
279
+ }
280
+ catch (e) {
281
+ gatewayLog.error({ err: e }, 'Failed to remove Discord Gateway token');
282
+ throw failed('Failed to remove token');
283
+ }
284
+ }),
285
+ rolesConfig: protectedProcedure.query(() => {
286
+ return { workerUrl: CONFIG.DISCORD_ROLES_WORKER_URL || null };
287
+ }),
288
+ });
@@ -0,0 +1,15 @@
1
+ import { protectedProcedure, router } from '../index.js';
2
+ import { getWatchlistDiscoveryStatus, triggerWatchlistDiscovery } from '../../hono/watchlist.js';
3
+ export const discoveryRouter = router({
4
+ status: protectedProcedure.query(() => {
5
+ return getWatchlistDiscoveryStatus();
6
+ }),
7
+ nudge: protectedProcedure.mutation(() => {
8
+ const started = triggerWatchlistDiscovery(false);
9
+ return { success: true, started, ...getWatchlistDiscoveryStatus() };
10
+ }),
11
+ refresh: protectedProcedure.mutation(() => {
12
+ const started = triggerWatchlistDiscovery(true);
13
+ return { success: true, started, ...getWatchlistDiscoveryStatus() };
14
+ }),
15
+ });
@@ -0,0 +1,45 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { getWatchInsights, getGenreCards } from '../../lib/insights.js';
4
+ import { computeDiscordSyncStats, getCachedRecommendations, getLinkedDiscordUser, setLinkedDiscordUser, syncDiscordRoles, } from '../../lib/discord-roles-sync.service.js';
5
+ import { defineSchema, reqObj } from '../validation.js';
6
+ const discordUserInput = () => defineSchema((value) => {
7
+ if (value === undefined || value === null)
8
+ return {};
9
+ return { user: reqObj(value).user };
10
+ });
11
+ export const insightsRouter = router({
12
+ summary: protectedProcedure.query(async ({ ctx }) => {
13
+ return getWatchInsights(ctx.db);
14
+ }),
15
+ genreCards: protectedProcedure.query(async ({ ctx }) => {
16
+ return getGenreCards(ctx.db);
17
+ }),
18
+ recommendations: protectedProcedure.query(async ({ ctx }) => {
19
+ return { candidates: await getCachedRecommendations(ctx.db) };
20
+ }),
21
+ discordSyncStats: protectedProcedure.query(async ({ ctx }) => {
22
+ return computeDiscordSyncStats(ctx.db);
23
+ }),
24
+ discordUser: protectedProcedure.query(async ({ ctx }) => {
25
+ return { user: await getLinkedDiscordUser(ctx.db) };
26
+ }),
27
+ setDiscordUser: protectedProcedure.input(discordUserInput()).mutation(async ({ ctx, input }) => {
28
+ const { user } = input;
29
+ if (user !== null &&
30
+ user !== undefined &&
31
+ (typeof user !== 'object' ||
32
+ typeof user.id !== 'string' ||
33
+ !user.id)) {
34
+ throw new TRPCError({
35
+ code: 'BAD_REQUEST',
36
+ message: 'Invalid user payload: expected { id, ... } or null',
37
+ });
38
+ }
39
+ await setLinkedDiscordUser(ctx.db, user || null);
40
+ return { success: true, user: user || null };
41
+ }),
42
+ discordSyncNow: protectedProcedure.mutation(async ({ ctx }) => {
43
+ return syncDiscordRoles(ctx.db, true);
44
+ }),
45
+ });
@@ -0,0 +1,280 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { performMangaWriteTransactionAsync } from '../../sync.js';
4
+ import { buildMangaId, MANGA_STATUSES, MangaLibraryRepository, MangaProgressRepository, } from '../../repositories/manga.repository.js';
5
+ import { defineSchema, optStr, reqObj } from '../validation.js';
6
+ function mangaDb(ctx) {
7
+ const db = ctx.mangaDb;
8
+ if (!db || db.isClosedCheck())
9
+ throw new Error('Manga database is not ready');
10
+ return db;
11
+ }
12
+ function normalizeId(provider, mangaId, idRaw) {
13
+ if (idRaw && idRaw.includes(':'))
14
+ return idRaw;
15
+ return buildMangaId(provider, mangaId || idRaw || '');
16
+ }
17
+ function badRequest(message) {
18
+ return new TRPCError({ code: 'BAD_REQUEST', message });
19
+ }
20
+ function failed(message) {
21
+ return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message });
22
+ }
23
+ function readCount(obj, name, fallback) {
24
+ const raw = obj[name];
25
+ if (raw === undefined || raw === null || raw === '')
26
+ return fallback;
27
+ const n = Math.floor(Number(raw));
28
+ return Number.isFinite(n) ? n : fallback;
29
+ }
30
+ const mangaLibraryListInput = () => defineSchema((value) => {
31
+ const obj = reqObj(value);
32
+ const status = optStr(obj, 'status');
33
+ const page = Math.max(readCount(obj, 'page', 1), 1);
34
+ const limit = Math.min(Math.max(readCount(obj, 'limit', 24), 1), 100);
35
+ return { status, page, limit };
36
+ });
37
+ const mangaLibraryIdInput = () => defineSchema((value) => {
38
+ const obj = reqObj(value);
39
+ const id = obj.id;
40
+ if (typeof id !== 'string')
41
+ throw new Error('id must be a string');
42
+ return { id };
43
+ });
44
+ const mangaLibraryAddInput = () => defineSchema((value) => {
45
+ const obj = reqObj(value);
46
+ const out = {
47
+ provider: optStr(obj, 'provider'),
48
+ mangaId: optStr(obj, 'mangaId'),
49
+ id: optStr(obj, 'id'),
50
+ title: optStr(obj, 'title'),
51
+ cover: optStr(obj, 'cover'),
52
+ status: optStr(obj, 'status'),
53
+ author: optStr(obj, 'author'),
54
+ altTitle: optStr(obj, 'altTitle'),
55
+ contentRating: optStr(obj, 'contentRating'),
56
+ };
57
+ if (typeof obj.silent === 'boolean')
58
+ out.silent = obj.silent;
59
+ return out;
60
+ });
61
+ const mangaLibraryRemoveInput = () => defineSchema((value) => {
62
+ if (typeof value !== 'string')
63
+ throw new Error('id must be a string');
64
+ return value;
65
+ });
66
+ const mangaLibraryStatusInput = () => defineSchema((value) => {
67
+ const obj = reqObj(value);
68
+ return { id: optStr(obj, 'id'), status: optStr(obj, 'status') };
69
+ });
70
+ const mangaLibraryLinkInput = () => defineSchema((value) => {
71
+ const obj = reqObj(value);
72
+ return { id: optStr(obj, 'id'), anilistId: obj.anilistId };
73
+ });
74
+ const mangaLibraryBatchStatusInput = () => defineSchema((value) => {
75
+ const obj = reqObj(value);
76
+ const idsRaw = obj.ids;
77
+ const ids = Array.isArray(idsRaw) ? idsRaw.map((id) => String(id)) : [];
78
+ return { ids, status: optStr(obj, 'status') };
79
+ });
80
+ const mangaLibraryRemoveManyInput = () => defineSchema((value) => {
81
+ if (!Array.isArray(value))
82
+ throw new Error('ids must be a non-empty array');
83
+ return value.map((id) => String(id));
84
+ });
85
+ export const mangaLibraryRouter = router({
86
+ list: protectedProcedure.input(mangaLibraryListInput()).query(async ({ ctx, input }) => {
87
+ try {
88
+ const offset = (input.page - 1) * input.limit;
89
+ const db = mangaDb(ctx);
90
+ const rows = await MangaLibraryRepository.getAll(db, input.status, input.limit, offset);
91
+ const total = await MangaLibraryRepository.getCount(db, input.status);
92
+ return { data: rows, total, page: input.page, limit: input.limit };
93
+ }
94
+ catch (err) {
95
+ if (err instanceof TRPCError)
96
+ throw err;
97
+ throw failed('Failed to load manga library');
98
+ }
99
+ }),
100
+ ids: protectedProcedure.query(async ({ ctx }) => {
101
+ try {
102
+ return { ids: await MangaLibraryRepository.getIds(mangaDb(ctx)) };
103
+ }
104
+ catch {
105
+ return { ids: [] };
106
+ }
107
+ }),
108
+ check: protectedProcedure.input(mangaLibraryIdInput()).query(async ({ ctx, input }) => {
109
+ try {
110
+ const item = await MangaLibraryRepository.getById(mangaDb(ctx), input.id);
111
+ return { inLibrary: !!item, status: item?.status ?? null };
112
+ }
113
+ catch {
114
+ return { inLibrary: false, status: null };
115
+ }
116
+ }),
117
+ entry: protectedProcedure.input(mangaLibraryIdInput()).query(async ({ ctx, input }) => {
118
+ try {
119
+ const item = await MangaLibraryRepository.getById(mangaDb(ctx), input.id);
120
+ if (!item)
121
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Not in library' });
122
+ return { item };
123
+ }
124
+ catch (err) {
125
+ if (err instanceof TRPCError)
126
+ throw err;
127
+ throw failed('Failed to load entry');
128
+ }
129
+ }),
130
+ add: protectedProcedure.input(mangaLibraryAddInput()).mutation(async ({ ctx, input }) => {
131
+ const provider = input.provider || '';
132
+ const mangaId = input.mangaId || '';
133
+ const idRaw = input.id || '';
134
+ if (!provider || (!mangaId && !idRaw)) {
135
+ throw badRequest('provider and mangaId are required');
136
+ }
137
+ const id = normalizeId(String(provider), String(mangaId || ''), idRaw ? String(idRaw) : undefined);
138
+ const cleanStatus = typeof input.status === 'string' && MANGA_STATUSES.includes(input.status)
139
+ ? input.status
140
+ : 'Reading';
141
+ try {
142
+ await performMangaWriteTransactionAsync(mangaDb(ctx), async (tx) => {
143
+ await MangaLibraryRepository.upsert(tx, {
144
+ id,
145
+ provider: String(provider).toLowerCase(),
146
+ mangaId: String(mangaId || idRaw || ''),
147
+ title: String(input.title || ''),
148
+ cover: String(input.cover || ''),
149
+ status: cleanStatus,
150
+ author: input.author ? String(input.author) : undefined,
151
+ altTitle: input.altTitle ? String(input.altTitle) : undefined,
152
+ contentRating: input.contentRating ? String(input.contentRating) : undefined,
153
+ });
154
+ });
155
+ return { success: true, id };
156
+ }
157
+ catch (err) {
158
+ if (err instanceof TRPCError)
159
+ throw err;
160
+ throw failed('Failed to bookmark manga');
161
+ }
162
+ }),
163
+ remove: protectedProcedure.input(mangaLibraryRemoveInput()).mutation(async ({ ctx, input }) => {
164
+ if (!input)
165
+ throw badRequest('id is required');
166
+ try {
167
+ await performMangaWriteTransactionAsync(mangaDb(ctx), async (tx) => {
168
+ await MangaLibraryRepository.delete(tx, String(input));
169
+ await MangaProgressRepository.deleteByManga(tx, String(input));
170
+ });
171
+ return { success: true };
172
+ }
173
+ catch (err) {
174
+ if (err instanceof TRPCError)
175
+ throw err;
176
+ throw failed('Failed to remove bookmark');
177
+ }
178
+ }),
179
+ setStatus: protectedProcedure
180
+ .input(mangaLibraryStatusInput())
181
+ .mutation(async ({ ctx, input }) => {
182
+ if (!input.id || !MANGA_STATUSES.includes(input.status)) {
183
+ throw badRequest('id and a valid status are required');
184
+ }
185
+ try {
186
+ await performMangaWriteTransactionAsync(mangaDb(ctx), async (tx) => {
187
+ await MangaLibraryRepository.updateStatus(tx, String(input.id), String(input.status));
188
+ });
189
+ return { success: true };
190
+ }
191
+ catch (err) {
192
+ if (err instanceof TRPCError)
193
+ throw err;
194
+ throw failed('Failed to update status');
195
+ }
196
+ }),
197
+ link: protectedProcedure.input(mangaLibraryLinkInput()).mutation(async ({ ctx, input }) => {
198
+ const targetId = typeof input.id === 'string' ? input.id : '';
199
+ const mediaId = Number(input.anilistId);
200
+ if (!targetId || !Number.isInteger(mediaId) || mediaId <= 0) {
201
+ throw badRequest('id and a numeric anilistId are required');
202
+ }
203
+ try {
204
+ const db = mangaDb(ctx);
205
+ const row = await MangaLibraryRepository.getById(db, targetId);
206
+ if (!row)
207
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Library entry not found' });
208
+ if (String(row.provider).toLowerCase() === 'anilist') {
209
+ throw badRequest('Link a provider bookmark to the AniList entry, not the reverse');
210
+ }
211
+ let progressMigrated = false;
212
+ await performMangaWriteTransactionAsync(db, async (tx) => {
213
+ await MangaLibraryRepository.setAnilistId(tx, targetId, mediaId, 'manual');
214
+ const orphanId = `anilist:${mediaId}`;
215
+ if (orphanId !== targetId) {
216
+ const orphan = await MangaLibraryRepository.getById(tx, orphanId);
217
+ const target = await MangaLibraryRepository.getById(tx, targetId);
218
+ const targetProgress = await MangaProgressRepository.getByManga(tx, targetId);
219
+ if (orphan?.lastChapterNumber &&
220
+ !target?.lastChapterNumber &&
221
+ targetProgress.length === 0) {
222
+ await MangaLibraryRepository.setProgressPointer(tx, targetId, orphan.lastChapterNumber);
223
+ await MangaProgressRepository.moveSyntheticChapters(tx, orphanId, targetId);
224
+ progressMigrated = true;
225
+ }
226
+ await MangaLibraryRepository.delete(tx, orphanId);
227
+ await MangaProgressRepository.deleteByManga(tx, orphanId);
228
+ }
229
+ });
230
+ return { success: true, id: targetId, progressMigrated };
231
+ }
232
+ catch (err) {
233
+ if (err instanceof TRPCError)
234
+ throw err;
235
+ throw failed('Failed to link entry');
236
+ }
237
+ }),
238
+ batchStatus: protectedProcedure
239
+ .input(mangaLibraryBatchStatusInput())
240
+ .mutation(async ({ ctx, input }) => {
241
+ if (!Array.isArray(input.ids) || input.ids.length === 0) {
242
+ throw badRequest('ids must be a non-empty array');
243
+ }
244
+ if (!input.status || !MANGA_STATUSES.includes(input.status)) {
245
+ throw badRequest('a valid status is required');
246
+ }
247
+ const ids = input.ids.map((id) => String(id));
248
+ try {
249
+ await performMangaWriteTransactionAsync(mangaDb(ctx), async (tx) => {
250
+ await MangaLibraryRepository.updateStatusMany(tx, ids, String(input.status));
251
+ });
252
+ return { success: true, updated: ids.length };
253
+ }
254
+ catch (err) {
255
+ if (err instanceof TRPCError)
256
+ throw err;
257
+ throw failed('Failed to update statuses');
258
+ }
259
+ }),
260
+ removeMany: protectedProcedure
261
+ .input(mangaLibraryRemoveManyInput())
262
+ .mutation(async ({ ctx, input }) => {
263
+ if (!Array.isArray(input) || input.length === 0) {
264
+ throw badRequest('ids must be a non-empty array');
265
+ }
266
+ const ids = input.map((id) => String(id));
267
+ try {
268
+ await performMangaWriteTransactionAsync(mangaDb(ctx), async (tx) => {
269
+ await MangaLibraryRepository.deleteMany(tx, ids);
270
+ await MangaProgressRepository.deleteMany(tx, ids);
271
+ });
272
+ return { success: true, removed: ids.length };
273
+ }
274
+ catch (err) {
275
+ if (err instanceof TRPCError)
276
+ throw err;
277
+ throw failed('Failed to remove bookmarks');
278
+ }
279
+ }),
280
+ });