@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,621 @@
1
+ import { Hono } from 'hono';
2
+ import { compress } from 'hono/compress';
3
+ import { getConnInfo } from '@hono/node-server/conninfo';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ import { XMLParser } from 'fast-xml-parser';
7
+ import { CONFIG } from './config.js';
8
+ import { isAllowedOrigin } from './utils/security.utils.js';
9
+ import { requestContext } from './utils/request-context.js';
10
+ import logger from './logger.js';
11
+ import { LAN_AUTH_PUBLIC_PATHS, buildLanCookie, clearAllLanSessions, clearLanCookie, createLanSession, getTokenFromHeaders, hasAppPassword, isLoopbackIp, revokeLanSession, setAppPassword, validateLanSession, verifyAppPassword, } from './app-auth.js';
12
+ import { importMangaFromMalXmlItems, } from './lib/tracker/manga-sync.service.js';
13
+ import { LibraryRepository } from './repositories/library.repository.js';
14
+ import { performAsmrWriteTransactionAsync, performMangaWriteTransactionAsync, performTvWriteTransactionAsync, performWriteTransactionAsync, setLocalAsmrManifestVersion, setLocalMangaManifestVersion, setLocalManifestVersion, setLocalTvManifestVersion, } from './sync.js';
15
+ import { ANIME_SYNC_TABLES, ASMR_SYNC_TABLES, MANGA_SYNC_TABLES, TV_SYNC_TABLES, exportTables, importTables, normalizePayload, readPayloadVersion, } from './sync-payload.js';
16
+ import { getMalImportStatus, prepareMalImport, executeMalImport, } from './lib/mal-import.js';
17
+ import { registerMusic } from './hono/music.js';
18
+ import { registerAuth } from './hono/auth.js';
19
+ import { registerWatchlist } from './hono/watchlist.js';
20
+ import { registerTv } from './hono/tv.js';
21
+ import { registerProxy } from './hono/proxy.js';
22
+ import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
23
+ import { appRouter } from './trpc/router.js';
24
+ import { buildTrpcContext } from './trpc/context.js';
25
+ const malMangaXmlParser = new XMLParser({
26
+ isArray: (name) => name === 'manga',
27
+ parseTagValue: false,
28
+ trimValues: true,
29
+ });
30
+ function mangaXmlText(value) {
31
+ if (Array.isArray(value))
32
+ return String(value[0] ?? '');
33
+ return String(value ?? '');
34
+ }
35
+ function mapMalMangaXmlStatus(status) {
36
+ switch (status) {
37
+ case 'Plan to Read':
38
+ return 'Planned';
39
+ case 'On Hold':
40
+ return 'On-Hold';
41
+ case 'Reading':
42
+ case 'Completed':
43
+ case 'Dropped':
44
+ return status;
45
+ default:
46
+ return 'Planned';
47
+ }
48
+ }
49
+ const gatewayLog = logger.child({ module: 'DiscordGatewayRoutes' });
50
+ const TRACKER_TOKEN_KEY = 'tracker_anilist_token';
51
+ const TRACKER_USER_KEY = 'tracker_anilist_user';
52
+ function maskToken(t) {
53
+ if (!t)
54
+ return 'none';
55
+ return t.slice(0, 8) + '...' + t.slice(-4);
56
+ }
57
+ export function getHonoClientIp(c) {
58
+ try {
59
+ return getConnInfo(c).remote.address;
60
+ }
61
+ catch {
62
+ return c.req.header('x-dango-client-ip') ?? undefined;
63
+ }
64
+ }
65
+ export async function honoRequestLogger(c, next) {
66
+ const start = process.hrtime.bigint();
67
+ const url = new URL(c.req.url);
68
+ const loggedUrl = `${url.pathname}${url.search}`;
69
+ await next();
70
+ const ms = Math.round((Number(process.hrtime.bigint() - start) / 1_000_000) * 10) / 10;
71
+ const status = c.res.status;
72
+ const payload = { method: c.req.method, url: loggedUrl, status, ms };
73
+ if (status >= 500) {
74
+ logger.warn(payload, 'request failed');
75
+ }
76
+ else if (!loggedUrl.startsWith('/api/proxy')) {
77
+ logger.trace(`${c.req.method} ${loggedUrl} ${status} ${ms}ms`);
78
+ }
79
+ }
80
+ export async function honoRequestContextMiddleware(c, next) {
81
+ const store = new Map();
82
+ const ua = c.req.header('x-animepahe-ua');
83
+ if (ua)
84
+ store.set('ua', ua);
85
+ const cookie = c.req.header('x-animepahe-cookie');
86
+ if (cookie)
87
+ store.set('cookie', cookie);
88
+ const jasmrUa = c.req.header('x-jasmr-ua');
89
+ if (jasmrUa)
90
+ store.set('jasmr_ua', jasmrUa);
91
+ const jasmrCookie = c.req.header('x-jasmr-cookie');
92
+ if (jasmrCookie)
93
+ store.set('jasmr_cookie', jasmrCookie);
94
+ return requestContext.run(store, () => next());
95
+ }
96
+ async function restoreJsonBackup(c, dbs, buffer) {
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(buffer.toString('utf-8'));
100
+ }
101
+ catch {
102
+ return c.json({ error: 'Invalid backup file.' }, 400);
103
+ }
104
+ const databases = parsed?.databases;
105
+ if (!databases || typeof databases !== 'object') {
106
+ return c.json({ error: 'Invalid backup file: missing databases.' }, 400);
107
+ }
108
+ const targets = [
109
+ {
110
+ key: 'anime',
111
+ db: dbs.db,
112
+ tables: ANIME_SYNC_TABLES,
113
+ libraryTables: ['watchlist', 'watched_episodes'],
114
+ backupName: 'pre-sync-backup.db',
115
+ setVersion: setLocalManifestVersion,
116
+ },
117
+ {
118
+ key: 'manga',
119
+ db: dbs.mangaDb,
120
+ tables: MANGA_SYNC_TABLES,
121
+ libraryTables: ['manga_library', 'manga_progress'],
122
+ backupName: 'pre-sync-manga-backup.db',
123
+ setVersion: setLocalMangaManifestVersion,
124
+ },
125
+ {
126
+ key: 'tv',
127
+ db: dbs.tvDb,
128
+ tables: TV_SYNC_TABLES,
129
+ libraryTables: ['tv_library', 'tv_progress'],
130
+ backupName: 'pre-sync-tv-backup.db',
131
+ setVersion: setLocalTvManifestVersion,
132
+ },
133
+ {
134
+ key: 'asmr',
135
+ db: dbs.asmrDb,
136
+ tables: ASMR_SYNC_TABLES,
137
+ libraryTables: ['asmr_library', 'asmr_progress'],
138
+ backupName: 'pre-sync-asmr-backup.db',
139
+ setVersion: setLocalAsmrManifestVersion,
140
+ },
141
+ ];
142
+ const present = targets.filter((t) => databases[t.key] !== undefined);
143
+ if (present.length === 0) {
144
+ return c.json({ error: 'Invalid backup file: no databases found.' }, 400);
145
+ }
146
+ const sections = new Map();
147
+ try {
148
+ for (const target of present) {
149
+ sections.set(target.key, normalizePayload(databases[target.key], target.tables, `backup ${target.key}`));
150
+ }
151
+ }
152
+ catch (err) {
153
+ logger.warn({ err }, 'Multi-database restore rejected: invalid payload');
154
+ return c.json({ error: 'Invalid backup file.' }, 400);
155
+ }
156
+ const restored = [];
157
+ try {
158
+ for (const target of present) {
159
+ const section = sections.get(target.key);
160
+ importTables(target.db, target.tables, section, {
161
+ libraryTables: target.libraryTables,
162
+ backupName: target.backupName,
163
+ });
164
+ await target.setVersion(readPayloadVersion(section));
165
+ restored.push(target.key);
166
+ }
167
+ }
168
+ catch (err) {
169
+ logger.error({ err, restored }, 'Multi-database restore failed partway');
170
+ return c.json({ error: 'Restore failed partway.', restored }, 500);
171
+ }
172
+ logger.warn({ restored }, 'Databases restored from backup file by user request');
173
+ return c.json({ success: true, message: 'Databases restored.', restored });
174
+ }
175
+ function restoreLegacyDatabase(c, dbs, buffer, dbAdmin) {
176
+ const dbName = CONFIG.IS_DEV ? CONFIG.DB_NAME_DEV : CONFIG.DB_NAME_PROD;
177
+ const tempPath = path.join(CONFIG.ROOT, 'restore_temp.db');
178
+ const dbPath = path.join(CONFIG.ROOT, dbName);
179
+ try {
180
+ fs.writeFileSync(tempPath, buffer);
181
+ }
182
+ catch (err) {
183
+ logger.error({ err }, 'Failed to stage legacy database restore file');
184
+ return Promise.resolve(c.json({ error: 'Failed to stage restore file.' }, 500));
185
+ }
186
+ const db = dbs.db;
187
+ return new Promise((resolve) => {
188
+ db.close((closeErr) => {
189
+ if (closeErr) {
190
+ resolve(c.json({ error: 'Failed to close database.' }, 500));
191
+ return;
192
+ }
193
+ try {
194
+ db.checkpoint();
195
+ }
196
+ catch (checkpointErr) {
197
+ logger.warn({ err: checkpointErr }, 'WAL checkpoint failed');
198
+ }
199
+ try {
200
+ if (fs.existsSync(`${dbPath}-wal`))
201
+ fs.unlinkSync(`${dbPath}-wal`);
202
+ if (fs.existsSync(`${dbPath}-shm`))
203
+ fs.unlinkSync(`${dbPath}-shm`);
204
+ }
205
+ catch (cleanupErr) {
206
+ logger.warn({ err: cleanupErr }, 'Failed to clean up WAL files');
207
+ }
208
+ fs.rename(tempPath, dbPath, async (renameErr) => {
209
+ if (renameErr) {
210
+ try {
211
+ dbAdmin.setDb(await dbAdmin.initializeDatabase(dbPath));
212
+ }
213
+ catch (e) {
214
+ logger.error({ err: e }, 'Failed to reopen DB after rename failure');
215
+ }
216
+ resolve(c.json({ error: 'Failed to replace database file.' }, 500));
217
+ return;
218
+ }
219
+ try {
220
+ dbAdmin.setDb(await dbAdmin.initializeDatabase(dbPath));
221
+ resolve(c.json({ success: true, message: 'Database restored.' }));
222
+ }
223
+ catch (e) {
224
+ logger.error({ err: e }, 'Failed to initialize restored database');
225
+ resolve(c.json({ error: 'Failed to initialize restored database.' }, 500));
226
+ }
227
+ });
228
+ });
229
+ });
230
+ }
231
+ export function createHonoApp(getState, getGuardState, getDbs, dbAdmin, providerApi, runSync, media) {
232
+ const app = new Hono();
233
+ app.use(honoRequestLogger);
234
+ app.use(honoRequestContextMiddleware);
235
+ app.get('/api/health', (c) => {
236
+ const state = getState();
237
+ if (state.shuttingDown) {
238
+ return c.json({ status: 'shutting-down', ready: false }, 503);
239
+ }
240
+ if (!state.dbReady) {
241
+ return c.json({ status: 'starting', ready: false }, 503);
242
+ }
243
+ return c.json({ status: 'ok', ready: true });
244
+ });
245
+ app.use(async (c, next) => {
246
+ const guard = getGuardState();
247
+ if (guard.shuttingDown) {
248
+ return c.text('Server is shutting down...', 503);
249
+ }
250
+ if (!guard.dbsReady) {
251
+ return c.text('Database initializing...', 503);
252
+ }
253
+ if (guard.bootSyncing &&
254
+ c.req.method !== 'GET' &&
255
+ c.req.method !== 'HEAD' &&
256
+ c.req.method !== 'OPTIONS') {
257
+ return c.text('Sync in progress...', 503);
258
+ }
259
+ await next();
260
+ });
261
+ const honoCompress = compress({ threshold: 1024 });
262
+ app.use(async (c, next) => {
263
+ if (c.req.header('x-no-compression')) {
264
+ await next();
265
+ return;
266
+ }
267
+ await honoCompress(c, next);
268
+ });
269
+ app.use(async (c, next) => {
270
+ const origin = c.req.header('origin');
271
+ if (c.req.method === 'OPTIONS') {
272
+ if (!origin || !isAllowedOrigin(origin)) {
273
+ await next();
274
+ return;
275
+ }
276
+ const reqHeaders = c.req.header('access-control-request-headers');
277
+ return new Response(null, {
278
+ status: 204,
279
+ headers: {
280
+ 'Access-Control-Allow-Origin': origin,
281
+ 'Access-Control-Allow-Credentials': 'true',
282
+ 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
283
+ ...(reqHeaders ? { 'Access-Control-Allow-Headers': reqHeaders } : {}),
284
+ Vary: 'Origin',
285
+ },
286
+ });
287
+ }
288
+ if (!origin || isAllowedOrigin(origin)) {
289
+ if (origin)
290
+ c.header('Access-Control-Allow-Origin', origin);
291
+ c.header('Access-Control-Allow-Credentials', 'true');
292
+ c.header('Vary', 'Origin');
293
+ }
294
+ await next();
295
+ });
296
+ app.use(async (c, next) => {
297
+ const method = c.req.method.toUpperCase();
298
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
299
+ await next();
300
+ return;
301
+ }
302
+ const secFetchSite = c.req.header('sec-fetch-site');
303
+ if (secFetchSite === 'cross-site') {
304
+ const origin = c.req.header('origin');
305
+ if (!isAllowedOrigin(origin)) {
306
+ return c.json({ error: 'Forbidden: cross-site request rejected' }, 403);
307
+ }
308
+ }
309
+ const origin = c.req.header('origin');
310
+ if (origin && !isAllowedOrigin(origin)) {
311
+ return c.json({ error: 'Forbidden: cross-origin request rejected' }, 403);
312
+ }
313
+ await next();
314
+ });
315
+ app.get('/api/auth/app-status', (c) => {
316
+ const authed = !hasAppPassword() ||
317
+ validateLanSession(getTokenFromHeaders(c.req.header('authorization'), c.req.header('cookie')));
318
+ return c.json({ hasPassword: hasAppPassword(), isAuthenticated: authed });
319
+ });
320
+ app.post('/api/auth/app-login', async (c) => {
321
+ const body = (await c.req.json().catch(() => undefined));
322
+ const password = String(body?.password || '');
323
+ if (!hasAppPassword()) {
324
+ return c.json({ success: true });
325
+ }
326
+ if (!password) {
327
+ return c.json({ error: 'Password required' }, 400);
328
+ }
329
+ if (!verifyAppPassword(password)) {
330
+ return c.json({ error: 'Invalid password' }, 401);
331
+ }
332
+ const session = createLanSession();
333
+ c.header('Set-Cookie', buildLanCookie(session.token, session.expiry));
334
+ return c.json({ success: true });
335
+ });
336
+ app.post('/api/auth/app-logout', (c) => {
337
+ revokeLanSession(getTokenFromHeaders(c.req.header('authorization'), c.req.header('cookie')));
338
+ c.header('Set-Cookie', clearLanCookie());
339
+ return c.json({ success: true });
340
+ });
341
+ app.post('/api/auth/app-setup', async (c) => {
342
+ if (!isLoopbackIp(c.req.header('x-dango-client-ip'))) {
343
+ return c.json({ error: 'Forbidden' }, 403);
344
+ }
345
+ const body = (await c.req.json().catch(() => undefined));
346
+ const password = String(body?.password || '');
347
+ try {
348
+ await setAppPassword(password);
349
+ clearAllLanSessions();
350
+ return c.json({ success: true, hasPassword: hasAppPassword() });
351
+ }
352
+ catch {
353
+ return c.json({ error: 'Failed to update password' }, 500);
354
+ }
355
+ });
356
+ app.use(async (c, next) => {
357
+ const path = c.req.path;
358
+ if (!path.startsWith('/api/')) {
359
+ await next();
360
+ return;
361
+ }
362
+ if (LAN_AUTH_PUBLIC_PATHS.has(path)) {
363
+ await next();
364
+ return;
365
+ }
366
+ if (path.startsWith('/api/internal/') && isLoopbackIp(getHonoClientIp(c))) {
367
+ await next();
368
+ return;
369
+ }
370
+ if (!hasAppPassword()) {
371
+ await next();
372
+ return;
373
+ }
374
+ if (validateLanSession(getTokenFromHeaders(c.req.header('authorization'), c.req.header('cookie')))) {
375
+ await next();
376
+ return;
377
+ }
378
+ return c.json({ error: 'LAN_AUTH_REQUIRED' }, 401);
379
+ });
380
+ app.all('/api/trpc/*', (c) => fetchRequestHandler({
381
+ endpoint: '/api/trpc',
382
+ req: c.req.raw,
383
+ router: appRouter,
384
+ createContext: () => buildTrpcContext({
385
+ ...getDbs(),
386
+ apiCache: media.getApiCache(),
387
+ getTvProvider: media.getTvProvider,
388
+ getMangaProvider: media.getMangaProvider,
389
+ getJasmr: media.getJasmr,
390
+ getProviders: media.getProviders,
391
+ getCatalog: providerApi.getCatalog,
392
+ }, c.req.header('authorization'), c.req.header('cookie')),
393
+ }));
394
+ app.post('/api/import/mal-xml', async (c) => {
395
+ const dbs = getDbs();
396
+ const fields = (await c.req.parseBody().catch(() => ({})));
397
+ const file = fields['xmlfile'];
398
+ const buffer = file instanceof File ? Buffer.from(await file.arrayBuffer()) : undefined;
399
+ const prepared = prepareMalImport(buffer, {
400
+ erase: fields['erase'],
401
+ useOfflineDb: fields['useOfflineDb'],
402
+ skipFallback: fields['skipFallback'],
403
+ });
404
+ if (!prepared.ok)
405
+ return c.json(prepared.body, prepared.status);
406
+ const stream = new TransformStream();
407
+ const writer = stream.writable.getWriter();
408
+ const encoder = new TextEncoder();
409
+ let disconnected = false;
410
+ c.req.raw.signal.addEventListener('abort', () => {
411
+ disconnected = true;
412
+ }, { once: true });
413
+ const sendEvent = (event, data) => {
414
+ if (disconnected)
415
+ return;
416
+ void writer
417
+ .write(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
418
+ .catch(() => { });
419
+ };
420
+ void executeMalImport(dbs.db, prepared.import, sendEvent).finally(() => {
421
+ void writer.close().catch(() => { });
422
+ });
423
+ return new Response(stream.readable, {
424
+ headers: {
425
+ 'Content-Type': 'text/event-stream',
426
+ 'Cache-Control': 'no-cache',
427
+ Connection: 'keep-alive',
428
+ },
429
+ });
430
+ });
431
+ app.get('/api/providers', (c) => {
432
+ c.header('Cache-Control', 'public, max-age=300');
433
+ return c.json(providerApi.getCatalog());
434
+ });
435
+ app.post('/api/providers/refresh', async (c) => {
436
+ try {
437
+ return c.json(await providerApi.refresh());
438
+ }
439
+ catch (err) {
440
+ return c.json({ error: err.message || 'refresh failed' }, 500);
441
+ }
442
+ });
443
+ app.get('/api/tracker/anilist/callback', (c) => {
444
+ return c.html(`<!doctype html>
445
+ <html><head><meta charset="utf-8"><title>AniList — completing login</title></head>
446
+ <body style="font-family:system-ui;padding:24px;background:#0b1426;color:#e5eefc"><p>Completing AniList login…</p>
447
+ <script>
448
+ (function(){
449
+ var qs = new URLSearchParams(location.search);
450
+ var state = qs.get('state');
451
+ var hash = location.hash || '';
452
+ var hp = new URLSearchParams(hash.slice(1));
453
+ if (!state) state = hp.get('state');
454
+ var token = hp.get('access_token');
455
+ var error = hp.get('error') || qs.get('error');
456
+ var frontend = location.origin + '/trackers';
457
+ if (state) {
458
+ try {
459
+ var candidate = decodeURIComponent(state);
460
+ if (candidate.startsWith('/') && !candidate.startsWith('//')) {
461
+ frontend = location.origin + candidate;
462
+ } else {
463
+ var parsed = new URL(candidate, location.origin);
464
+ if (parsed.origin === location.origin) {
465
+ frontend = parsed.href;
466
+ }
467
+ }
468
+ } catch(e) {}
469
+ }
470
+ if (frontend.indexOf('/api/tracker/anilist/callback') !== -1) {
471
+ frontend = location.origin + '/trackers';
472
+ }
473
+ if (error) {
474
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=' + encodeURIComponent(error));
475
+ return;
476
+ }
477
+ if (token) {
478
+ location.replace(frontend + hash);
479
+ return;
480
+ }
481
+ var code = qs.get('code');
482
+ if (code) {
483
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=code_flow_removed');
484
+ return;
485
+ }
486
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=no_token');
487
+ })();
488
+ </script></body></html>`);
489
+ });
490
+ app.post('/api/import/mal-xml-manga', async (c) => {
491
+ const fields = (await c.req.parseBody().catch(() => ({})));
492
+ const file = fields['xmlfile'];
493
+ if (!(file instanceof File))
494
+ return c.json({ error: 'No file' }, 400);
495
+ const eraseRaw = fields['erase'];
496
+ const erase = eraseRaw === true || eraseRaw === 'true' || eraseRaw === '1';
497
+ try {
498
+ const dbs = getDbs();
499
+ if (!dbs.mangaDb || dbs.mangaDb.isClosedCheck()) {
500
+ return c.json({ error: 'Manga database is not ready' }, 503);
501
+ }
502
+ let result;
503
+ try {
504
+ result = malMangaXmlParser.parse(Buffer.from(await file.arrayBuffer()).toString());
505
+ }
506
+ catch {
507
+ return c.json({ error: 'Invalid XML' }, 400);
508
+ }
509
+ const mangaList = result?.myanimelist?.manga || [];
510
+ if (mangaList.length === 0) {
511
+ return c.json({ error: 'No manga found in XML' }, 400);
512
+ }
513
+ const items = mangaList.map((item) => ({
514
+ malId: parseInt(mangaXmlText(item.series_mangadb_id), 10) || 0,
515
+ title: mangaXmlText(item.series_title),
516
+ status: mapMalMangaXmlStatus(mangaXmlText(item.my_status)),
517
+ chapters: Math.max(parseInt(mangaXmlText(item.my_read_chapters), 10) || 0, 0),
518
+ }));
519
+ const { imported, skipped } = await importMangaFromMalXmlItems(dbs.db, dbs.mangaDb, items, erase);
520
+ return c.json({ success: true, imported, skipped });
521
+ }
522
+ catch (err) {
523
+ const message = err instanceof Error ? err.message : 'Import failed';
524
+ return c.json({ error: message }, 500);
525
+ }
526
+ });
527
+ app.get('/api/backup-db', (c) => {
528
+ try {
529
+ const dbs = getDbs();
530
+ const payload = {
531
+ version: 1,
532
+ exportedAt: new Date().toISOString(),
533
+ databases: {
534
+ anime: exportTables(dbs.db, ANIME_SYNC_TABLES),
535
+ manga: exportTables(dbs.mangaDb, MANGA_SYNC_TABLES),
536
+ tv: exportTables(dbs.tvDb, TV_SYNC_TABLES),
537
+ asmr: exportTables(dbs.asmrDb, ASMR_SYNC_TABLES),
538
+ },
539
+ };
540
+ c.header('Content-Disposition', 'attachment; filename="dango-backup.json"');
541
+ return c.json(payload);
542
+ }
543
+ catch (err) {
544
+ logger.error({ err }, 'Multi-database backup failed');
545
+ return c.json({ error: 'Backup failed' }, 500);
546
+ }
547
+ });
548
+ app.post('/api/database/clear', async (c) => {
549
+ const body = (await c.req.json().catch(() => undefined));
550
+ if (body?.confirm !== true) {
551
+ return c.json({ error: 'Confirmation required' }, 400);
552
+ }
553
+ if (getMalImportStatus().running) {
554
+ return c.json({ error: 'An import is in progress' }, 409);
555
+ }
556
+ try {
557
+ const dbs = getDbs();
558
+ const before = await LibraryRepository.countAll(dbs.db);
559
+ const beforeManga = await LibraryRepository.countManga(dbs.mangaDb);
560
+ const beforeTv = await LibraryRepository.countTv(dbs.tvDb);
561
+ const beforeAsmr = await LibraryRepository.countAsmr(dbs.asmrDb);
562
+ await performWriteTransactionAsync(dbs.db, async (tx) => {
563
+ await LibraryRepository.clearAll(tx);
564
+ });
565
+ await performMangaWriteTransactionAsync(dbs.mangaDb, async (tx) => {
566
+ await LibraryRepository.clearManga(tx);
567
+ });
568
+ await performTvWriteTransactionAsync(dbs.tvDb, async (tx) => {
569
+ await LibraryRepository.clearTv(tx);
570
+ });
571
+ await performAsmrWriteTransactionAsync(dbs.asmrDb, async (tx) => {
572
+ await LibraryRepository.clearAsmr(tx);
573
+ });
574
+ logger.warn({ before, beforeManga, beforeTv, beforeAsmr }, 'Library database cleared by user request');
575
+ return c.json({
576
+ success: true,
577
+ deleted: before,
578
+ deletedManga: beforeManga,
579
+ deletedTv: beforeTv,
580
+ deletedAsmr: beforeAsmr,
581
+ });
582
+ }
583
+ catch {
584
+ return c.json({ error: 'DB error' }, 500);
585
+ }
586
+ });
587
+ app.post('/api/restore-db', async (c) => {
588
+ const dbs = getDbs();
589
+ let buffer;
590
+ let originalName = '';
591
+ try {
592
+ const body = await c.req.parseBody();
593
+ const file = body['dbfile'];
594
+ if (file instanceof File) {
595
+ buffer = Buffer.from(await file.arrayBuffer());
596
+ originalName = file.name || '';
597
+ }
598
+ }
599
+ catch {
600
+ return c.json({ error: 'No file uploaded.' }, 400);
601
+ }
602
+ if (!buffer)
603
+ return c.json({ error: 'No file uploaded.' }, 400);
604
+ const head = buffer.slice(0, 16).toString('utf-8').trimStart();
605
+ if (originalName.endsWith('.json') || head.startsWith('{')) {
606
+ return restoreJsonBackup(c, dbs, buffer);
607
+ }
608
+ return restoreLegacyDatabase(c, dbs, buffer, dbAdmin);
609
+ });
610
+ registerMusic(app);
611
+ registerAuth(app, getDbs, runSync);
612
+ registerWatchlist(app, getDbs);
613
+ registerTv(app, media.getApiCache, media.getTvProvider);
614
+ registerProxy(app);
615
+ app.onError((err, c) => {
616
+ logger.error({ err, url: c.req.path, method: c.req.method }, 'Unhandled error');
617
+ const status = err.status || 500;
618
+ return c.json({ error: err.message || 'Internal Server Error', status }, status);
619
+ });
620
+ return app;
621
+ }
@@ -46,7 +46,6 @@ function migrateLegacyData(packageServerRoot, dataRoot) {
46
46
  for (const filename of legacyFiles) {
47
47
  moveFileIfNeeded(path.join(packageServerRoot, filename), path.join(dataRoot, filename));
48
48
  }
49
- // Manga library database + manifests (added after anime DB; migrate if present)
50
49
  for (const filename of [
51
50
  'manga.db',
52
51
  'manga.db-shm',
@@ -0,0 +1,32 @@
1
+ import { drizzle } from 'drizzle-orm/sqlite-proxy';
2
+ export function createDrizzle(db) {
3
+ const callback = async (sql, params, method) => {
4
+ const args = params;
5
+ if (method === 'run') {
6
+ db.run(sql, args);
7
+ return { rows: [] };
8
+ }
9
+ if (method === 'get') {
10
+ const row = db.get(sql, args);
11
+ return { rows: row === undefined ? [] : [row] };
12
+ }
13
+ const rows = db.all(sql, args);
14
+ if (method === 'values') {
15
+ return { rows: rows.map((r) => Object.values(r)) };
16
+ }
17
+ return { rows };
18
+ };
19
+ return drizzle(callback);
20
+ }
21
+ const cache = new WeakMap();
22
+ export function getDrizzle(db) {
23
+ let d = cache.get(db);
24
+ if (!d) {
25
+ d = createDrizzle(db);
26
+ cache.set(db, d);
27
+ }
28
+ return d;
29
+ }
30
+ export async function runTx(db, fn) {
31
+ await db.transact(fn);
32
+ }