@serifpersia/dango 3.3.4

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 (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
  4. package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
  5. package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
  6. package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
  7. package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
  8. package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
  9. package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
  10. package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
  11. package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
  12. package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
  13. package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
  14. package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
  15. package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
  16. package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
  17. package/client/dist/assets/Home-DtRtTuI9.css +1 -0
  18. package/client/dist/assets/Home-thJhPfs1.js +1 -0
  19. package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
  20. package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
  21. package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
  22. package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
  23. package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
  24. package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
  25. package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
  26. package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
  27. package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
  28. package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
  29. package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
  30. package/client/dist/assets/Map-Dj51g5zL.js +1 -0
  31. package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
  32. package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
  33. package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
  34. package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
  35. package/client/dist/assets/Music-BGH_0voi.css +1 -0
  36. package/client/dist/assets/Music-zsPwT8_5.js +1 -0
  37. package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
  38. package/client/dist/assets/Player-B036Ic3m.css +1 -0
  39. package/client/dist/assets/Player-BEtAYps2.js +56 -0
  40. package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
  41. package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
  42. package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
  43. package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
  44. package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
  45. package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
  46. package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
  47. package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
  48. package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
  49. package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
  50. package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
  51. package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
  52. package/client/dist/assets/Search-C8e2nlDD.js +1 -0
  53. package/client/dist/assets/Search-Cs45oAwI.css +1 -0
  54. package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
  55. package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
  56. package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
  57. package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
  58. package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
  59. package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
  60. package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
  61. package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
  62. package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
  63. package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
  64. package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
  65. package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
  66. package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
  67. package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
  68. package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
  69. package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
  70. package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
  71. package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
  72. package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
  73. package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
  74. package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
  75. package/client/dist/assets/hls-BOLXTnjL.js +42 -0
  76. package/client/dist/assets/hls-C4cG9wQw.js +1 -0
  77. package/client/dist/assets/index-BOKlZEHq.js +30354 -0
  78. package/client/dist/assets/index-CUm1meqr.css +1 -0
  79. package/client/dist/assets/index-IZgh1beL.js +178 -0
  80. package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
  81. package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
  82. package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
  83. package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
  84. package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
  85. package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
  86. package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
  87. package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
  88. package/client/dist/assets/useTv-D37MfTHA.js +1 -0
  89. package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
  90. package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
  91. package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
  92. package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
  93. package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
  94. package/client/dist/av-sync-test.mp4 +0 -0
  95. package/client/dist/favicon.ico +0 -0
  96. package/client/dist/index.html +46 -0
  97. package/client/dist/logo-mochi.png +0 -0
  98. package/client/dist/logo-sakura.png +0 -0
  99. package/client/dist/logo.png +0 -0
  100. package/client/dist/placeholder.svg +4 -0
  101. package/client/dist/robots.txt +3 -0
  102. package/orchestrator.js +534 -0
  103. package/package.json +85 -0
  104. package/server/dist/app-auth.js +177 -0
  105. package/server/dist/config.js +175 -0
  106. package/server/dist/controllers/asmr-library.controller.js +288 -0
  107. package/server/dist/controllers/auth.controller.js +225 -0
  108. package/server/dist/controllers/data.controller.js +766 -0
  109. package/server/dist/controllers/insights.controller.js +198 -0
  110. package/server/dist/controllers/manga-library.controller.js +296 -0
  111. package/server/dist/controllers/music.controller.js +698 -0
  112. package/server/dist/controllers/proxy.controller.js +820 -0
  113. package/server/dist/controllers/settings.controller.js +609 -0
  114. package/server/dist/controllers/tv-library.controller.js +310 -0
  115. package/server/dist/controllers/watchlist.controller.js +1015 -0
  116. package/server/dist/db.js +145 -0
  117. package/server/dist/discord-gateway.js +391 -0
  118. package/server/dist/discord-rpc.js +427 -0
  119. package/server/dist/github-sync.js +512 -0
  120. package/server/dist/google.js +748 -0
  121. package/server/dist/lib/anilist.js +1549 -0
  122. package/server/dist/lib/anischedule.js +194 -0
  123. package/server/dist/lib/discord-roles-sync.service.js +130 -0
  124. package/server/dist/lib/ipc.js +24 -0
  125. package/server/dist/lib/kitsu.js +544 -0
  126. package/server/dist/lib/mal.js +934 -0
  127. package/server/dist/lib/migration.js +329 -0
  128. package/server/dist/lib/offline-db.js +401 -0
  129. package/server/dist/lib/temp-ids.js +3 -0
  130. package/server/dist/lib/tmdb.js +72 -0
  131. package/server/dist/lib/tracker/anilist-tracker.js +245 -0
  132. package/server/dist/lib/tracker/sync.service.js +496 -0
  133. package/server/dist/lib/ytmusic.js +125 -0
  134. package/server/dist/logger.js +16 -0
  135. package/server/dist/providers/manga/manga.types.js +7 -0
  136. package/server/dist/providers/provider.interface.js +1 -0
  137. package/server/dist/providers/remote-loader.js +574 -0
  138. package/server/dist/providers/remote-types.js +117 -0
  139. package/server/dist/providers/title-matching.js +225 -0
  140. package/server/dist/providers/tv.types.js +1 -0
  141. package/server/dist/rclone.js +120 -0
  142. package/server/dist/repositories/asmr.repository.js +123 -0
  143. package/server/dist/repositories/insights.repository.js +85 -0
  144. package/server/dist/repositories/library.repository.js +108 -0
  145. package/server/dist/repositories/mal-cache.repository.js +55 -0
  146. package/server/dist/repositories/manga.repository.js +138 -0
  147. package/server/dist/repositories/notifications.repository.js +41 -0
  148. package/server/dist/repositories/queue.repository.js +61 -0
  149. package/server/dist/repositories/settings.repository.js +12 -0
  150. package/server/dist/repositories/shows-meta.repository.js +42 -0
  151. package/server/dist/repositories/temp-show-ids.repository.js +51 -0
  152. package/server/dist/repositories/tv.repository.js +151 -0
  153. package/server/dist/repositories/watched-episodes.repository.js +52 -0
  154. package/server/dist/repositories/watchlist.repository.js +71 -0
  155. package/server/dist/request-logger.js +16 -0
  156. package/server/dist/routes/asmr-library.routes.js +23 -0
  157. package/server/dist/routes/asmr.routes.js +81 -0
  158. package/server/dist/routes/auth.routes.js +25 -0
  159. package/server/dist/routes/data.routes.js +47 -0
  160. package/server/dist/routes/discord-gateway.routes.js +51 -0
  161. package/server/dist/routes/insights.routes.js +34 -0
  162. package/server/dist/routes/lan-auth.routes.js +46 -0
  163. package/server/dist/routes/manga-library.routes.js +23 -0
  164. package/server/dist/routes/manga.routes.js +141 -0
  165. package/server/dist/routes/music.routes.js +20 -0
  166. package/server/dist/routes/providers.routes.js +17 -0
  167. package/server/dist/routes/proxy.routes.js +12 -0
  168. package/server/dist/routes/radio.routes.js +138 -0
  169. package/server/dist/routes/settings.routes.js +24 -0
  170. package/server/dist/routes/tracker.routes.js +164 -0
  171. package/server/dist/routes/translate.routes.js +88 -0
  172. package/server/dist/routes/tv-library.routes.js +23 -0
  173. package/server/dist/routes/tv.routes.js +629 -0
  174. package/server/dist/routes/watchlist.routes.js +341 -0
  175. package/server/dist/scripts/backfill-episode-duration.js +42 -0
  176. package/server/dist/server.js +529 -0
  177. package/server/dist/shipped-defaults.js +7 -0
  178. package/server/dist/sync-config.js +23 -0
  179. package/server/dist/sync-payload.js +91 -0
  180. package/server/dist/sync.js +1128 -0
  181. package/server/dist/utils/cache.utils.js +25 -0
  182. package/server/dist/utils/cookie.utils.js +13 -0
  183. package/server/dist/utils/db-utils.js +9 -0
  184. package/server/dist/utils/env.utils.js +77 -0
  185. package/server/dist/utils/http.utils.js +70 -0
  186. package/server/dist/utils/machine-id.js +40 -0
  187. package/server/dist/utils/megaplay.utils.js +41 -0
  188. package/server/dist/utils/request-context.js +2 -0
  189. package/server/dist/utils/security.utils.js +94 -0
  190. package/server/package.json +44 -0
@@ -0,0 +1,138 @@
1
+ import { Router } from 'express';
2
+ import logger from '../logger.js';
3
+ import { parseJsonBody } from '../utils/http.utils.js';
4
+ const LISTEN_MOE_STATIONS = [
5
+ {
6
+ id: 'listenmoe-jpop',
7
+ name: 'LISTEN.moe JPOP',
8
+ streamUrl: 'https://listen.moe/fallback',
9
+ homepage: 'https://listen.moe/',
10
+ tags: 'anime,j-pop,jpop',
11
+ codec: 'MP3',
12
+ source: 'listen.moe',
13
+ gateway: 'wss://listen.moe/gateway_v2',
14
+ },
15
+ {
16
+ id: 'listenmoe-kpop',
17
+ name: 'LISTEN.moe KPOP',
18
+ streamUrl: 'https://listen.moe/kpop/fallback',
19
+ homepage: 'https://listen.moe/',
20
+ tags: 'k-pop,kpop',
21
+ codec: 'MP3',
22
+ source: 'listen.moe',
23
+ gateway: 'wss://listen.moe/kpop/gateway_v2',
24
+ },
25
+ ];
26
+ const FEATURED_STATIONS = [
27
+ {
28
+ id: 'stereo-anime',
29
+ name: 'stereo anime',
30
+ streamUrl: 'https://radio.stereoanime.com/listen/stereoanime/128',
31
+ tags: 'anime',
32
+ codec: 'MP3',
33
+ bitrate: 128,
34
+ source: 'radio-browser',
35
+ },
36
+ {
37
+ id: 'jpop-sakura',
38
+ name: 'J-Pop Sakura (asia DREAM radio)',
39
+ streamUrl: 'https://quincy.torontocast.com:2070/stream.mp3',
40
+ tags: 'j-pop,jpop',
41
+ codec: 'MP3',
42
+ bitrate: 128,
43
+ source: 'radio-browser',
44
+ },
45
+ {
46
+ id: 'n-kpop',
47
+ name: '0 N - K-Pop on Radio',
48
+ streamUrl: 'https://0n-kpop.radionetz.de/0n-kpop.mp3',
49
+ tags: 'k-pop,kpop',
50
+ codec: 'MP3',
51
+ bitrate: 128,
52
+ source: 'radio-browser',
53
+ },
54
+ {
55
+ id: 'bigb-kpop',
56
+ name: 'Big B Radio - Kpop',
57
+ streamUrl: 'https://antares.dribbcast.com/proxy/kpop?mp=/s',
58
+ tags: 'k-pop,kpop',
59
+ codec: 'MP3',
60
+ bitrate: 192,
61
+ source: 'radio-browser',
62
+ },
63
+ ];
64
+ const RB_MIRRORS = ['https://de1.api.radio-browser.info', 'https://nl1.api.radio-browser.info'];
65
+ async function rbFetch(path) {
66
+ let lastErr = null;
67
+ for (const base of RB_MIRRORS) {
68
+ try {
69
+ const res = await fetch(`${base}${path}`, {
70
+ headers: { 'User-Agent': 'dango/1.0' },
71
+ signal: AbortSignal.timeout(10000),
72
+ });
73
+ if (!res.ok)
74
+ throw new Error(`radio-browser ${res.status}`);
75
+ return parseJsonBody(res);
76
+ }
77
+ catch (err) {
78
+ lastErr = err;
79
+ }
80
+ }
81
+ throw lastErr;
82
+ }
83
+ function toStation(s) {
84
+ const url = s.url_resolved || '';
85
+ if (!url.startsWith('https://'))
86
+ return null;
87
+ return {
88
+ id: s.stationuuid || url,
89
+ name: s.name || 'Unknown station',
90
+ streamUrl: url,
91
+ homepage: s.homepage || undefined,
92
+ favicon: s.favicon && s.favicon.startsWith('https://') ? s.favicon : undefined,
93
+ tags: s.tags || undefined,
94
+ codec: s.codec || undefined,
95
+ bitrate: s.bitrate || undefined,
96
+ source: 'radio-browser',
97
+ };
98
+ }
99
+ export function createRadioRouter(apiCache) {
100
+ const router = Router();
101
+ router.get('/radio/stations', async (_req, res) => {
102
+ try {
103
+ const cached = apiCache.get('route-radio-stations');
104
+ if (cached)
105
+ return res.json({ stations: cached });
106
+ const stations = [...LISTEN_MOE_STATIONS, ...FEATURED_STATIONS];
107
+ apiCache.set('route-radio-stations', stations, 3600);
108
+ res.json({ stations });
109
+ }
110
+ catch (err) {
111
+ logger.error({ err }, '[Radio] stations failed');
112
+ res.json({ stations: LISTEN_MOE_STATIONS });
113
+ }
114
+ });
115
+ router.get('/radio/search', async (req, res) => {
116
+ try {
117
+ const q = String(req.query.q || '').trim();
118
+ if (!q)
119
+ return res.json({ stations: [] });
120
+ const cacheKey = `route-radio-search-${q.toLowerCase()}`;
121
+ const cached = apiCache.get(cacheKey);
122
+ if (cached)
123
+ return res.json({ stations: cached });
124
+ const raw = (await rbFetch(`/json/stations/search?name=${encodeURIComponent(q)}&hidebroken=true&order=clickcount&reverse=true&limit=25`));
125
+ const stations = raw
126
+ .map(toStation)
127
+ .filter((s) => s !== null)
128
+ .slice(0, 25);
129
+ apiCache.set(cacheKey, stations, 300);
130
+ res.json({ stations });
131
+ }
132
+ catch (err) {
133
+ logger.error({ err }, '[Radio] search failed');
134
+ res.json({ stations: [] });
135
+ }
136
+ });
137
+ return router;
138
+ }
@@ -0,0 +1,24 @@
1
+ import { Router } from 'express';
2
+ import { SettingsController } from '../controllers/settings.controller.js';
3
+ import multer from 'multer';
4
+ export function createSettingsRouter(getDb, initializeDatabase, setDb) {
5
+ const router = Router();
6
+ const controller = new SettingsController();
7
+ router.get('/settings', controller.getSettings);
8
+ router.post('/settings', controller.updateSettings);
9
+ router.get('/backup-db', controller.backupDatabase);
10
+ router.post('/database/clear', controller.clearDatabase);
11
+ router.get('/installation-id', controller.getInstallationId);
12
+ router.get('/settings/offline-db', controller.getOfflineDbInfo);
13
+ router.post('/settings/offline-db/update', controller.updateOfflineDb);
14
+ router.post('/settings/offline-db/auto-update', controller.setAutoUpdateOfflineDb);
15
+ const restoreStorage = multer({
16
+ storage: multer.memoryStorage(),
17
+ limits: { fileSize: 256 * 1024 * 1024 },
18
+ });
19
+ router.post('/restore-db', restoreStorage.single('dbfile'), (req, res) => controller.restoreDatabase(req, res, getDb(), initializeDatabase, setDb));
20
+ router.post('/import/mal-xml', multer().single('xmlfile'), controller.importMalXml);
21
+ router.get('/import/mal-xml/status', controller.getImportStatus);
22
+ router.post('/import/mal-xml/cancel', controller.cancelImport);
23
+ return router;
24
+ }
@@ -0,0 +1,164 @@
1
+ import { Router } from 'express';
2
+ import { AniListTracker } from '../lib/tracker/anilist-tracker.js';
3
+ import { syncAniList, importFromMalUsername, importFromUsername, } from '../lib/tracker/sync.service.js';
4
+ import { SettingsRepository } from '../repositories/settings.repository.js';
5
+ import { performWriteTransaction } from '../sync.js';
6
+ const TOKEN_KEY = 'tracker_anilist_token';
7
+ const USER_KEY = 'tracker_anilist_user';
8
+ const CLIENT_ID_KEY = 'tracker_anilist_client_id';
9
+ export function createTrackerRouter() {
10
+ const router = Router();
11
+ router.get('/tracker/anilist/callback', (_req, res) => {
12
+ res.type('html').send(`<!doctype html>
13
+ <html><head><meta charset="utf-8"><title>AniList — completing login</title></head>
14
+ <body style="font-family:system-ui;padding:24px;background:#0b1426;color:#e5eefc"><p>Completing AniList login…</p>
15
+ <script>
16
+ (function(){
17
+ var qs = new URLSearchParams(location.search);
18
+ var state = qs.get('state');
19
+ var hash = location.hash || '';
20
+ var hp = new URLSearchParams(hash.slice(1));
21
+ if (!state) state = hp.get('state');
22
+ var token = hp.get('access_token');
23
+ var error = hp.get('error') || qs.get('error');
24
+ var frontend = location.origin + '/trackers';
25
+ if (state) {
26
+ try {
27
+ var candidate = decodeURIComponent(state);
28
+ if (candidate.startsWith('/') && !candidate.startsWith('//')) {
29
+ frontend = location.origin + candidate;
30
+ } else {
31
+ var parsed = new URL(candidate, location.origin);
32
+ if (parsed.origin === location.origin) {
33
+ frontend = parsed.href;
34
+ }
35
+ }
36
+ } catch(e) {}
37
+ }
38
+ if (frontend.indexOf('/api/tracker/anilist/callback') !== -1) {
39
+ frontend = location.origin + '/trackers';
40
+ }
41
+ if (error) {
42
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=' + encodeURIComponent(error));
43
+ return;
44
+ }
45
+ if (token) {
46
+ location.replace(frontend + hash);
47
+ return;
48
+ }
49
+ var code = qs.get('code');
50
+ if (code) {
51
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=code_flow_removed');
52
+ return;
53
+ }
54
+ location.replace(frontend + (frontend.indexOf('?') !== -1 ? '&' : '?') + 'anilist=error&reason=no_token');
55
+ })();
56
+ </script></body></html>`);
57
+ });
58
+ router.get('/tracker/status', async (req, res) => {
59
+ try {
60
+ const tokenRow = await SettingsRepository.getByKey(req.db, TOKEN_KEY);
61
+ const userRow = await SettingsRepository.getByKey(req.db, 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
+ res.json({
72
+ anilist: { connected: !!tokenRow?.value, user },
73
+ });
74
+ }
75
+ catch {
76
+ res.status(500).json({ error: 'Failed to read tracker status' });
77
+ }
78
+ });
79
+ router.post('/tracker/anilist/auth', async (req, res) => {
80
+ const { token } = req.body ?? {};
81
+ const accessToken = typeof token === 'string' ? token.trim() : '';
82
+ if (!accessToken) {
83
+ return res.status(400).json({ error: 'Access token is required' });
84
+ }
85
+ try {
86
+ const tracker = new AniListTracker(accessToken);
87
+ const viewer = await tracker.getViewer();
88
+ await performWriteTransaction(req.db, (tx) => {
89
+ SettingsRepository.upsert(tx, TOKEN_KEY, accessToken);
90
+ SettingsRepository.upsert(tx, USER_KEY, JSON.stringify(viewer));
91
+ });
92
+ res.json({ success: true, user: viewer });
93
+ }
94
+ catch (err) {
95
+ const message = err instanceof Error ? err.message : 'Authentication failed';
96
+ res.status(401).json({ error: message });
97
+ }
98
+ });
99
+ router.post('/tracker/anilist/disconnect', async (req, res) => {
100
+ try {
101
+ await performWriteTransaction(req.db, (tx) => {
102
+ SettingsRepository.upsert(tx, TOKEN_KEY, '');
103
+ SettingsRepository.upsert(tx, USER_KEY, '');
104
+ });
105
+ res.json({ success: true });
106
+ }
107
+ catch {
108
+ res.status(500).json({ error: 'Failed to disconnect' });
109
+ }
110
+ });
111
+ router.post('/tracker/sync', async (req, res) => {
112
+ const { provider = 'anilist' } = req.body ?? {};
113
+ if (provider !== 'anilist') {
114
+ return res.status(400).json({ error: `Provider "${provider}" is not supported yet` });
115
+ }
116
+ try {
117
+ const summary = await syncAniList(req.db);
118
+ res.json({ success: true, summary });
119
+ }
120
+ catch (err) {
121
+ const message = err instanceof Error ? err.message : 'Sync failed';
122
+ res.status(500).json({ error: message });
123
+ }
124
+ });
125
+ router.post('/tracker/anilist/import', async (req, res) => {
126
+ const { username, erase } = req.body ?? {};
127
+ if (!username || typeof username !== 'string') {
128
+ return res.status(400).json({ error: 'Username is required' });
129
+ }
130
+ try {
131
+ const count = await importFromUsername(req.db, username.trim(), erase === true);
132
+ res.json({ success: true, count });
133
+ }
134
+ catch (err) {
135
+ const message = err instanceof Error ? err.message : 'Import failed';
136
+ res.status(500).json({ error: message });
137
+ }
138
+ });
139
+ router.post('/tracker/mal/import', async (req, res) => {
140
+ const { username, erase, useOfflineDb, skipFallback } = req.body ?? {};
141
+ if (!username || typeof username !== 'string') {
142
+ return res.status(400).json({ error: 'MAL username is required' });
143
+ }
144
+ try {
145
+ const count = await importFromMalUsername(req.db, username.trim(), {
146
+ erase: erase === true,
147
+ useOfflineDb: useOfflineDb !== false,
148
+ skipFallback: skipFallback === true,
149
+ });
150
+ res.json({ success: true, count });
151
+ }
152
+ catch (err) {
153
+ const message = err instanceof Error ? err.message : 'Import failed';
154
+ if (message.includes('private') || message.includes('not found')) {
155
+ return res.status(404).json({ error: message });
156
+ }
157
+ if (message.includes('blocked') || message.includes('HTTP 429')) {
158
+ return res.status(429).json({ error: message });
159
+ }
160
+ res.status(500).json({ error: message });
161
+ }
162
+ });
163
+ return router;
164
+ }
@@ -0,0 +1,88 @@
1
+ import { Router } from 'express';
2
+ import { AppCache } from '../utils/cache.utils.js';
3
+ import { parseJsonBody } from '../utils/http.utils.js';
4
+ const cache = new AppCache({
5
+ ttlSeconds: 7 * 24 * 60 * 60,
6
+ maxKeys: 5000,
7
+ });
8
+ function toTitleCase(s) {
9
+ return s
10
+ .split(' ')
11
+ .map((w, i) => {
12
+ if (i !== 0 &&
13
+ ['x', 'and', 'or', 'the', 'a', 'an', 'of', 'in', 'on', 'with'].includes(w.toLowerCase())) {
14
+ return w.toLowerCase();
15
+ }
16
+ return w.charAt(0).toUpperCase() + w.slice(1);
17
+ })
18
+ .join(' ')
19
+ .replace(/\bX\b/g, 'x');
20
+ }
21
+ function normalize(s) {
22
+ const t = s.trim();
23
+ if (!t)
24
+ return t;
25
+ return toTitleCase(t);
26
+ }
27
+ export function createTranslateRouter() {
28
+ const router = Router();
29
+ router.post('/translate', async (req, res) => {
30
+ const { texts, source = 'ja', target = 'en' } = req.body ?? {};
31
+ if (!Array.isArray(texts) || texts.length === 0) {
32
+ return res.json({ translations: {} });
33
+ }
34
+ const jobs = texts.map((t) => String(t).trim()).filter(Boolean);
35
+ const result = {};
36
+ const toFetch = [];
37
+ for (const t of jobs) {
38
+ const key = `${source}:${target}:${t}`;
39
+ const cached = cache.get(key);
40
+ if (cached)
41
+ result[t] = cached;
42
+ else
43
+ toFetch.push(t);
44
+ }
45
+ if (toFetch.length > 0) {
46
+ const chunks = [];
47
+ for (let i = 0; i < toFetch.length; i += 15)
48
+ chunks.push(toFetch.slice(i, i + 15));
49
+ for (const chunk of chunks) {
50
+ const q = chunk.join('\n');
51
+ const url = `https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl=${encodeURIComponent(source)}&tl=${encodeURIComponent(target)}&dt=t&q=${encodeURIComponent(q)}`;
52
+ try {
53
+ const r = await fetch(url, {
54
+ headers: { 'User-Agent': 'Mozilla/5.0' },
55
+ signal: AbortSignal.timeout(5000),
56
+ });
57
+ if (!r.ok)
58
+ throw new Error(`translate ${r.status}`);
59
+ const data = (await parseJsonBody(r));
60
+ let translated = '';
61
+ if (Array.isArray(data) && typeof data[0] === 'string') {
62
+ translated = data[0];
63
+ }
64
+ else if (Array.isArray(data) && Array.isArray(data[0])) {
65
+ const outer = data;
66
+ translated = outer[0]?.map((p) => p[0]).join('') || '';
67
+ }
68
+ const parts = translated.split('\n');
69
+ chunk.forEach((orig, i) => {
70
+ const trans = (parts[i] ?? '').trim();
71
+ const norm = trans ? normalize(trans) : orig;
72
+ result[orig] = norm;
73
+ cache.set(`${source}:${target}:${orig}`, norm);
74
+ });
75
+ }
76
+ catch {
77
+ for (const orig of chunk) {
78
+ if (!result[orig])
79
+ result[orig] = orig;
80
+ }
81
+ }
82
+ await new Promise((r) => setTimeout(r, 80));
83
+ }
84
+ }
85
+ res.json({ translations: result });
86
+ });
87
+ return router;
88
+ }
@@ -0,0 +1,23 @@
1
+ import { Router } from 'express';
2
+ import { TvLibraryController } from '../controllers/tv-library.controller.js';
3
+ export function createTvLibraryRouter() {
4
+ const router = Router();
5
+ const controller = new TvLibraryController();
6
+ router.get('/tv/library', controller.getLibrary);
7
+ router.get('/tv/library/ids', controller.getLibraryIds);
8
+ router.get('/tv/library/check/:id', controller.checkLibrary);
9
+ router.post('/tv/library/add', controller.addToLibrary);
10
+ router.post('/tv/library/remove', controller.removeFromLibrary);
11
+ router.post('/tv/library/status', controller.updateStatus);
12
+ router.post('/tv/library/batch-status', controller.batchUpdateStatus);
13
+ router.post('/tv/library/remove-many', controller.batchRemove);
14
+ router.post('/tv/progress/remove-many', controller.batchRemoveProgress);
15
+ router.get('/tv/progress/:mediaId', controller.getProgress);
16
+ router.get('/tv/progress/:mediaId/latest', controller.getLatestProgress);
17
+ router.post('/tv/progress', controller.saveProgress);
18
+ router.post('/tv/progress/remove', controller.removeProgress);
19
+ router.get('/tv/continue-watching', controller.getContinueWatching);
20
+ router.get('/tv/continue-watching/adult-count', controller.getAdultContinueWatchingCount);
21
+ router.post('/tv/continue-watching/purge-adult', controller.purgeAdultContinueWatching);
22
+ return router;
23
+ }