@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,145 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import logger from './logger.js';
5
+ export class DatabaseWrapper {
6
+ db;
7
+ isClosed = false;
8
+ statementCache = new Map();
9
+ constructor(_dbPath, db) {
10
+ this.db = db;
11
+ }
12
+ static async create(dbPath) {
13
+ try {
14
+ const dir = path.dirname(dbPath);
15
+ if (!fs.existsSync(dir)) {
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+ const db = new DatabaseSync(dbPath);
19
+ return new DatabaseWrapper(dbPath, db);
20
+ }
21
+ catch (e) {
22
+ logger.error({ err: e }, `Failed to initialize database at ${dbPath}`);
23
+ throw e;
24
+ }
25
+ }
26
+ scheduleSave() { }
27
+ async saveNow() { }
28
+ configure(option, value) {
29
+ if (option === 'busyTimeout') {
30
+ this.db.exec(`PRAGMA busy_timeout = ${value}`);
31
+ }
32
+ }
33
+ serialize(cb) {
34
+ this.db.exec('BEGIN IMMEDIATE');
35
+ try {
36
+ cb();
37
+ this.db.exec('COMMIT');
38
+ }
39
+ catch (e) {
40
+ this.db.exec('ROLLBACK');
41
+ throw e;
42
+ }
43
+ }
44
+ close(cb) {
45
+ if (this.isClosed) {
46
+ if (cb)
47
+ cb(null);
48
+ return;
49
+ }
50
+ try {
51
+ this.isClosed = true;
52
+ this.statementCache.clear();
53
+ this.db.close();
54
+ if (cb)
55
+ cb(null);
56
+ }
57
+ catch (e) {
58
+ logger.error({ err: e }, 'Error during database close');
59
+ if (cb)
60
+ cb(e);
61
+ }
62
+ }
63
+ isClosedCheck() {
64
+ return this.isClosed;
65
+ }
66
+ getPreparedStatement(query) {
67
+ let stmt = this.statementCache.get(query);
68
+ if (!stmt) {
69
+ if (this.statementCache.size > 100) {
70
+ this.statementCache.clear();
71
+ }
72
+ stmt = this.db.prepare(query);
73
+ this.statementCache.set(query, stmt);
74
+ }
75
+ return stmt;
76
+ }
77
+ run(query, params = []) {
78
+ if (this.isClosed) {
79
+ throw new Error('Database is closed');
80
+ }
81
+ const stmt = this.getPreparedStatement(query);
82
+ if (params.length > 0) {
83
+ stmt.run(...params);
84
+ }
85
+ else {
86
+ stmt.run();
87
+ }
88
+ }
89
+ get(query, params = []) {
90
+ if (this.isClosed) {
91
+ throw new Error('Database is closed');
92
+ }
93
+ const stmt = this.getPreparedStatement(query);
94
+ if (params.length > 0) {
95
+ return stmt.get(...params);
96
+ }
97
+ return stmt.get();
98
+ }
99
+ all(query, params = []) {
100
+ if (this.isClosed) {
101
+ throw new Error('Database is closed');
102
+ }
103
+ const stmt = this.getPreparedStatement(query);
104
+ if (params.length > 0) {
105
+ return stmt.all(...params);
106
+ }
107
+ return stmt.all();
108
+ }
109
+ prepare(query) {
110
+ const stmt = this.getPreparedStatement(query);
111
+ return {
112
+ run: (...args) => {
113
+ stmt.run(...args);
114
+ },
115
+ all: () => {
116
+ return stmt.all();
117
+ },
118
+ get: () => {
119
+ return stmt.get();
120
+ },
121
+ finalize: () => { },
122
+ };
123
+ }
124
+ backup(backupPath) {
125
+ try {
126
+ if (fs.existsSync(backupPath)) {
127
+ fs.rmSync(backupPath, { force: true });
128
+ }
129
+ this.db.exec(`VACUUM INTO '${backupPath}'`);
130
+ }
131
+ catch (e) {
132
+ logger.error({ err: e, backupPath }, 'Database backup failed via VACUUM INTO');
133
+ throw e;
134
+ }
135
+ }
136
+ checkpoint() {
137
+ try {
138
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
139
+ }
140
+ catch (e) {
141
+ logger.error({ err: e }, 'Database WAL checkpoint failed');
142
+ throw e;
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,391 @@
1
+ import WebSocket from 'ws';
2
+ import logger from './logger.js';
3
+ import { CONFIG } from './config.js';
4
+ import dotenv from 'dotenv';
5
+ const log = logger.child({ module: 'DiscordGateway' });
6
+ class DiscordGatewayService {
7
+ gateway = null;
8
+ heartbeat = null;
9
+ token = null;
10
+ isConnected = false;
11
+ reconnectTimeout = null;
12
+ lastActivity = null;
13
+ isEnabled = false;
14
+ RECONNECT_DELAY = 15000;
15
+ externalAssetCache = new Map();
16
+ setEnabled(enabled) {
17
+ this.isEnabled = enabled;
18
+ if (enabled) {
19
+ this.loadToken();
20
+ this.connect();
21
+ }
22
+ else {
23
+ this.disconnect();
24
+ }
25
+ }
26
+ get serviceEnabled() {
27
+ return this.isEnabled && !!this.token && this.token.length > 30;
28
+ }
29
+ hasToken() {
30
+ return !!this.token && this.token.length > 30;
31
+ }
32
+ loadToken() {
33
+ this.token = process.env.DISCORD_GATEWAY_TOKEN || null;
34
+ }
35
+ reloadToken() {
36
+ dotenv.config({ path: CONFIG.ENV_PATH, override: true });
37
+ this.loadToken();
38
+ if (this.serviceEnabled) {
39
+ this.disconnect();
40
+ this.connect();
41
+ }
42
+ }
43
+ async getExternalAsset(url) {
44
+ if (!CONFIG.DISCORD_CLIENT_ID || !this.token)
45
+ return null;
46
+ const cached = this.externalAssetCache.get(url);
47
+ if (cached)
48
+ return cached;
49
+ try {
50
+ const res = await fetch(`https://discord.com/api/v9/applications/${CONFIG.DISCORD_CLIENT_ID}/external-assets`, {
51
+ method: 'POST',
52
+ headers: {
53
+ Authorization: this.token,
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body: JSON.stringify({ urls: [url] }),
57
+ });
58
+ const text = await res.text();
59
+ if (!res.ok) {
60
+ log.warn(`external-assets proxy failed ${res.status}: ${text.slice(0, 200)}`);
61
+ return null;
62
+ }
63
+ const data = JSON.parse(text);
64
+ if (!data[0]?.external_asset_path)
65
+ return null;
66
+ const mp = `mp:${data[0].external_asset_path}`;
67
+ this.externalAssetCache.set(url, mp);
68
+ return mp;
69
+ }
70
+ catch (err) {
71
+ log.warn({ err }, 'external-assets proxy error');
72
+ return null;
73
+ }
74
+ }
75
+ resolveImageUrl(data) {
76
+ const isSafeUrl = (url) => {
77
+ if (!url.startsWith('https://'))
78
+ return false;
79
+ if (url.includes('localhost') || url.includes('127.0.0.1'))
80
+ return false;
81
+ if (url.includes('s4.anilist.co') || url.includes('anilistcdn'))
82
+ return true;
83
+ const blocked = [
84
+ 'youtube-anime.com',
85
+ 'animepahe',
86
+ 'animeya.cc',
87
+ 'gogocdn.net',
88
+ 'weeabo0.xyz',
89
+ 'japaneseasmr.com',
90
+ ];
91
+ return !blocked.some((d) => url.includes(d));
92
+ };
93
+ let thumb = data.thumbnail;
94
+ if (thumb && thumb.includes('/api/image-proxy')) {
95
+ const m = thumb.match(/url=([^&]+)/);
96
+ if (m) {
97
+ try {
98
+ thumb = decodeURIComponent(m[1]);
99
+ }
100
+ catch (_e) {
101
+ void _e;
102
+ }
103
+ }
104
+ }
105
+ if (thumb && data.providerName !== 'AnimePahe' && isSafeUrl(thumb))
106
+ return thumb;
107
+ if (data.thumbnails) {
108
+ const safe = data.thumbnails.find((t) => t && (t.includes('s4.anilist.co') || t.includes('anilistcdn')));
109
+ if (safe)
110
+ return safe;
111
+ }
112
+ if (thumb && isSafeUrl(thumb))
113
+ return thumb;
114
+ return null;
115
+ }
116
+ async updatePresence(data) {
117
+ this.lastActivity = data;
118
+ if (!this.isEnabled || !this.gateway || this.gateway.readyState !== WebSocket.OPEN)
119
+ return;
120
+ const name = 'dango';
121
+ const details = data.title;
122
+ const state = data.stateLine
123
+ ? data.stateLine
124
+ : `Episode ${data.episode}${data.totalEpisodes ? `/${data.totalEpisodes}` : ''}`;
125
+ const type = data.providerName === 'ASMR' || data.providerName === 'Radio' || data.providerName === 'Music'
126
+ ? 2
127
+ : 3;
128
+ const imageUrl = this.resolveImageUrl(data);
129
+ let assets;
130
+ if (imageUrl) {
131
+ if (CONFIG.DISCORD_CLIENT_ID) {
132
+ const proxied = await this.getExternalAsset(imageUrl);
133
+ if (proxied) {
134
+ assets = {
135
+ large_image: proxied,
136
+ large_text: data.title.slice(0, 128),
137
+ small_image: 'small',
138
+ small_text: 'dango',
139
+ };
140
+ }
141
+ else {
142
+ assets = { large_image: 'logo', large_text: data.title.slice(0, 128) };
143
+ }
144
+ }
145
+ }
146
+ else if (CONFIG.DISCORD_CLIENT_ID) {
147
+ assets = { large_image: 'logo', large_text: data.title.slice(0, 128) };
148
+ }
149
+ const presence = {
150
+ since: null,
151
+ activities: [
152
+ {
153
+ name,
154
+ type,
155
+ state: data.isPlaying ? state : `${state} (Paused)`,
156
+ details,
157
+ ...(CONFIG.DISCORD_CLIENT_ID ? { application_id: CONFIG.DISCORD_CLIENT_ID } : {}),
158
+ ...(assets ? { assets } : {}),
159
+ buttons: ['Learn More'],
160
+ metadata: { button_urls: ['https://github.com/serifpersia/dango'] },
161
+ status_display_type: 2,
162
+ },
163
+ ],
164
+ status: 'online',
165
+ afk: false,
166
+ };
167
+ const nowMs = Date.now();
168
+ if (data.isPlaying) {
169
+ if (data.currentTime && data.currentTime > 0) {
170
+ presence.since = nowMs - Math.round(data.currentTime * 1000);
171
+ }
172
+ if (data.duration && data.duration > data.currentTime) {
173
+ presence.activities[0].timestamps = {
174
+ start: nowMs - Math.round(data.currentTime * 1000),
175
+ end: nowMs + Math.round((data.duration - data.currentTime) * 1000),
176
+ };
177
+ }
178
+ }
179
+ try {
180
+ this.gateway.send(JSON.stringify({ op: 3, d: presence }));
181
+ }
182
+ catch (err) {
183
+ log.error({ err }, 'Failed to send gateway presence update');
184
+ }
185
+ }
186
+ setIdleStatus(page) {
187
+ if (!this.isEnabled || !this.gateway || this.gateway.readyState !== WebSocket.OPEN)
188
+ return;
189
+ const pageLabels = {
190
+ home: { details: 'Home', state: 'Browsing anime' },
191
+ search: { details: 'Search', state: 'Exploring titles...' },
192
+ watchlist: { details: 'Watchlist', state: 'Reviewing the watchlist' },
193
+ anime: { details: 'Anime Info', state: 'Reading show details' },
194
+ insights: { details: 'Insights', state: 'Reviewing stats' },
195
+ settings: { details: 'Settings', state: 'Tweaking preferences' },
196
+ trackers: { details: 'Trackers', state: 'Syncing watchlists' },
197
+ map: { details: 'Map', state: 'Exploring the global user map' },
198
+ asmr: { details: 'ASMR', state: 'Browsing ASMR works' },
199
+ radio: { details: 'Radio', state: 'Browsing radio stations' },
200
+ music: { details: 'Music', state: 'Browsing music' },
201
+ tv: { details: 'TV', state: 'Browsing movies & shows' },
202
+ manga: { details: 'Manga', state: 'Browsing manga' },
203
+ };
204
+ const label = pageLabels[page] ?? { details: 'dango', state: 'Idle' };
205
+ try {
206
+ this.gateway.send(JSON.stringify({
207
+ op: 3,
208
+ d: {
209
+ since: null,
210
+ activities: [
211
+ {
212
+ name: 'dango',
213
+ type: page === 'asmr' || page === 'radio' || page === 'music' ? 2 : 3,
214
+ state: label.state,
215
+ details: label.details,
216
+ ...(CONFIG.DISCORD_CLIENT_ID ? { application_id: CONFIG.DISCORD_CLIENT_ID } : {}),
217
+ ...(CONFIG.DISCORD_CLIENT_ID
218
+ ? { assets: { large_image: 'logo', large_text: 'dango' } }
219
+ : {}),
220
+ buttons: ['Learn More'],
221
+ metadata: { button_urls: ['https://github.com/serifpersia/dango'] },
222
+ status_display_type: 2,
223
+ },
224
+ ],
225
+ status: 'online',
226
+ afk: false,
227
+ },
228
+ }));
229
+ }
230
+ catch (err) {
231
+ log.error({ err }, 'Failed to send gateway idle status');
232
+ }
233
+ }
234
+ clearPresence() {
235
+ if (!this.isEnabled)
236
+ return;
237
+ if (this.gateway && this.gateway.readyState === WebSocket.OPEN) {
238
+ try {
239
+ this.gateway.send(JSON.stringify({
240
+ op: 3,
241
+ d: { since: null, activities: [], status: 'online', afk: false },
242
+ }));
243
+ }
244
+ catch {
245
+ // ignore
246
+ }
247
+ }
248
+ this.lastActivity = null;
249
+ }
250
+ shutdown() {
251
+ this.isEnabled = false;
252
+ this.token = null;
253
+ this.disconnect();
254
+ }
255
+ disconnect() {
256
+ if (this.reconnectTimeout) {
257
+ clearTimeout(this.reconnectTimeout);
258
+ this.reconnectTimeout = null;
259
+ }
260
+ if (this.gateway) {
261
+ try {
262
+ if (this.gateway.readyState === WebSocket.OPEN) {
263
+ this.gateway.send(JSON.stringify({
264
+ op: 3,
265
+ d: { since: null, activities: [], status: 'online', afk: false },
266
+ }));
267
+ }
268
+ this.gateway.close();
269
+ }
270
+ catch {
271
+ // ignore
272
+ }
273
+ this.gateway = null;
274
+ }
275
+ if (this.heartbeat) {
276
+ clearInterval(this.heartbeat);
277
+ this.heartbeat = null;
278
+ }
279
+ this.isConnected = false;
280
+ }
281
+ connect() {
282
+ if (!this.isEnabled || !this.token || this.gateway)
283
+ return;
284
+ const wsUrl = 'wss://gateway.discord.gg/?encoding=json&v=10';
285
+ log.debug('Connecting Discord gateway');
286
+ this.gateway = new WebSocket(wsUrl);
287
+ let heartbeatInterval = 41250;
288
+ let responded = false;
289
+ this.gateway.on('message', (data) => {
290
+ try {
291
+ const msg = JSON.parse(data.toString());
292
+ if (msg.op === 10) {
293
+ heartbeatInterval = msg.d.heartbeat_interval;
294
+ this.heartbeat = setInterval(() => {
295
+ try {
296
+ if (this.gateway && this.gateway.readyState === WebSocket.OPEN) {
297
+ this.gateway.send(JSON.stringify({ op: 1, d: null }));
298
+ }
299
+ }
300
+ catch {
301
+ // ignore
302
+ }
303
+ }, heartbeatInterval);
304
+ const identify = {
305
+ op: 2,
306
+ d: {
307
+ token: this.token,
308
+ capabilities: 1021,
309
+ client_state: {
310
+ guild_hashes: {},
311
+ highest_last_message_id: '0',
312
+ private_channels_version: '0',
313
+ read_state_version: 0,
314
+ user_guild_settings_version: -1,
315
+ user_settings_version: -1,
316
+ },
317
+ compress: false,
318
+ presence: {
319
+ since: null,
320
+ activities: [],
321
+ status: 'online',
322
+ afk: false,
323
+ },
324
+ properties: {
325
+ os: 'Windows',
326
+ os_version: '10.0.19044',
327
+ os_arch: 'x64',
328
+ browser: 'Discord Client',
329
+ client_version: '0.0.20',
330
+ client_build_number: 152131,
331
+ client_event_source: null,
332
+ release_channel: 'stable',
333
+ system_locale: 'en-US',
334
+ },
335
+ },
336
+ };
337
+ if (this.gateway) {
338
+ this.gateway.send(JSON.stringify(identify));
339
+ }
340
+ }
341
+ else if (msg.op === 11) {
342
+ // heartbeat ack
343
+ }
344
+ else if (msg.op === 0) {
345
+ if (msg.t === 'READY') {
346
+ responded = true;
347
+ this.isConnected = true;
348
+ log.info(`Gateway READY as ${msg.d.user?.username || 'unknown'}`);
349
+ if (this.lastActivity) {
350
+ void this.updatePresence(this.lastActivity).catch((err) => log.warn({ err }, 'failed to restore presence after READY'));
351
+ }
352
+ }
353
+ }
354
+ else if (msg.op === 7) {
355
+ log.warn('Gateway reconnect requested by Discord');
356
+ this.handleDisconnect();
357
+ }
358
+ else if (msg.op === 9) {
359
+ log.warn('Gateway invalid session, reconnecting...');
360
+ this.handleDisconnect();
361
+ }
362
+ }
363
+ catch (err) {
364
+ log.debug({ err }, 'Gateway message parse error');
365
+ }
366
+ });
367
+ this.gateway.on('close', (code, reason) => {
368
+ log.warn(`Gateway closed ${code} ${reason.toString().slice(0, 100)}`);
369
+ this.handleDisconnect();
370
+ });
371
+ this.gateway.on('error', (err) => {
372
+ log.error(err, 'Gateway WebSocket error');
373
+ this.handleDisconnect();
374
+ });
375
+ }
376
+ handleDisconnect() {
377
+ this.isConnected = false;
378
+ if (this.heartbeat) {
379
+ clearInterval(this.heartbeat);
380
+ this.heartbeat = null;
381
+ }
382
+ this.gateway = null;
383
+ if (this.isEnabled && this.token) {
384
+ this.reconnectTimeout = setTimeout(() => {
385
+ this.reconnectTimeout = null;
386
+ this.connect();
387
+ }, this.RECONNECT_DELAY);
388
+ }
389
+ }
390
+ }
391
+ export const discordGatewayService = new DiscordGatewayService();