@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,401 @@
1
+ import crypto from 'crypto';
2
+ import logger from '../logger.js';
3
+ import { SettingsRepository } from '../repositories/settings.repository.js';
4
+ import { decodeMaybeGzip } from '../utils/http.utils.js';
5
+ const log = logger.child({ module: 'OfflineDb' });
6
+ const OFFLINE_DB_URLS = [
7
+ 'https://github.com/cedya77/anime-offline-database/releases/latest/download/anime-offline-database-minified.json',
8
+ 'https://raw.githubusercontent.com/cedya77/anime-offline-database/master/anime-offline-database-minified.json',
9
+ 'https://github.com/manami-project/anime-offline-database/releases/latest/download/anime-offline-database-minified.json',
10
+ 'https://raw.githubusercontent.com/manami-project/anime-offline-database/master/anime-offline-database-minified.json',
11
+ ];
12
+ const FETCH_IDLE_TIMEOUT_MS = 60000;
13
+ const FETCH_OVERALL_TIMEOUT_MS = 30 * 60 * 1000;
14
+ const MAX_BODY_BYTES = 100 * 1024 * 1024;
15
+ const CHUNK_SIZE = 1000;
16
+ const INTERRUPTED_RETRY_COOLDOWN_MS = 60 * 60 * 1000;
17
+ const CANONICAL_GENRES = [
18
+ 'Action',
19
+ 'Adventure',
20
+ 'Comedy',
21
+ 'Drama',
22
+ 'Fantasy',
23
+ 'Horror',
24
+ 'Mahou Shoujo',
25
+ 'Mecha',
26
+ 'Music',
27
+ 'Mystery',
28
+ 'Psychological',
29
+ 'Romance',
30
+ 'Sci-Fi',
31
+ 'Slice of Life',
32
+ 'Sports',
33
+ 'Supernatural',
34
+ 'Thriller',
35
+ ];
36
+ const canonicalGenreMap = new Map(CANONICAL_GENRES.map((g) => [g.toLowerCase(), g]));
37
+ const SCHEMA_VERSION = 1;
38
+ const TABLE = 'anime_id_map';
39
+ const STAGING_TABLE = 'anime_id_map_new';
40
+ function ensureTable(db, table = TABLE) {
41
+ const row = db.get("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [table]);
42
+ if (!row) {
43
+ db.run(`CREATE TABLE ${table} (id INTEGER PRIMARY KEY AUTOINCREMENT, anilist_id INTEGER, mal_id INTEGER, title TEXT, thumbnail TEXT, type TEXT, genres TEXT)`);
44
+ }
45
+ else {
46
+ const columns = db.all(`PRAGMA table_info(${table})`);
47
+ const names = new Set(columns.map((c) => c.name));
48
+ if (!names.has('mal_id'))
49
+ db.run(`ALTER TABLE ${table} ADD COLUMN mal_id INTEGER`);
50
+ if (!names.has('type'))
51
+ db.run(`ALTER TABLE ${table} ADD COLUMN type TEXT`);
52
+ if (!names.has('genres'))
53
+ db.run(`ALTER TABLE ${table} ADD COLUMN genres TEXT`);
54
+ }
55
+ db.run(`CREATE INDEX IF NOT EXISTS idx_anime_id_map_anilist ON ${table}(anilist_id)`);
56
+ db.run(`CREATE INDEX IF NOT EXISTS idx_anime_id_map_mal ON ${table}(mal_id)`);
57
+ }
58
+ function latestSaturdayUtc() {
59
+ const now = new Date();
60
+ const daysSinceSaturday = (now.getUTCDay() + 1) % 7;
61
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - daysSinceSaturday, 0, 0, 0, 0));
62
+ }
63
+ async function fetchText(url) {
64
+ const controller = new AbortController();
65
+ const overall = setTimeout(() => controller.abort(), FETCH_OVERALL_TIMEOUT_MS);
66
+ let idle;
67
+ const touchIdle = () => {
68
+ if (idle)
69
+ clearTimeout(idle);
70
+ idle = setTimeout(() => controller.abort(), FETCH_IDLE_TIMEOUT_MS);
71
+ };
72
+ try {
73
+ const res = await fetch(url, {
74
+ signal: controller.signal,
75
+ headers: { 'User-Agent': 'Dango-Media-Client/1.0', Accept: 'application/json' },
76
+ });
77
+ if (!res.ok)
78
+ throw new Error(`HTTP ${res.status}`);
79
+ const length = res.headers.get('content-length');
80
+ if (length && Number(length) > MAX_BODY_BYTES)
81
+ throw new Error('Response too large');
82
+ if (!res.body)
83
+ throw new Error('Empty response body');
84
+ touchIdle();
85
+ const chunks = [];
86
+ let total = 0;
87
+ const reader = res.body.getReader();
88
+ try {
89
+ for (;;) {
90
+ const { done, value } = await reader.read();
91
+ if (done)
92
+ break;
93
+ touchIdle();
94
+ total += value.byteLength;
95
+ if (total > MAX_BODY_BYTES)
96
+ throw new Error('Response too large');
97
+ chunks.push(Buffer.from(value));
98
+ }
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
103
+ const text = decodeMaybeGzip(Buffer.concat(chunks)).toString('utf8');
104
+ if (text.length > MAX_BODY_BYTES)
105
+ throw new Error('Response too large');
106
+ return text;
107
+ }
108
+ finally {
109
+ if (idle)
110
+ clearTimeout(idle);
111
+ clearTimeout(overall);
112
+ }
113
+ }
114
+ class OfflineDb {
115
+ malToEntry = new Map();
116
+ anilistToMal = new Map();
117
+ anilistToEntry = new Map();
118
+ isInitialized = false;
119
+ isRefreshing = false;
120
+ async init(db) {
121
+ try {
122
+ ensureTable(db);
123
+ this.loadCache(db);
124
+ log.info(`OfflineDb loaded ${this.malToEntry.size} MAL mappings from SQLite`);
125
+ const wasInterrupted = SettingsRepository.getByKey(db, 'offlineDbLastStatus')?.value === 'updating';
126
+ const autoUpdateSetting = SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
127
+ const autoUpdateEnabled = !autoUpdateSetting || autoUpdateSetting.value !== 'false';
128
+ this.backfillShowsMetaGenres(db);
129
+ if (this.malToEntry.size === 0) {
130
+ log.info('OfflineDb cache is empty. Starting background download...');
131
+ this.refreshDatabase(db).catch((err) => {
132
+ log.warn({ err: err?.message }, 'Background offline database download failed');
133
+ });
134
+ }
135
+ else if (wasInterrupted && autoUpdateEnabled && this.interruptedRetryDue(db)) {
136
+ log.info('Previous offline database refresh was interrupted. Retrying in background...');
137
+ SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
138
+ SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Retrying automatically...');
139
+ this.refreshDatabase(db).catch((err) => {
140
+ log.warn({ err: err?.message }, 'Interrupted offline database retry failed');
141
+ });
142
+ }
143
+ else if (wasInterrupted) {
144
+ SettingsRepository.upsert(db, 'offlineDbLastStatus', 'idle');
145
+ SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Previous refresh was interrupted by a restart. Trigger an update to retry.');
146
+ }
147
+ this.isInitialized = true;
148
+ }
149
+ catch (e) {
150
+ log.error({ err: e }, 'Failed to initialize OfflineDb');
151
+ }
152
+ }
153
+ backfillShowsMetaGenres(db) {
154
+ try {
155
+ ensureTable(db);
156
+ db.run(`
157
+ UPDATE shows_meta
158
+ SET genres = (
159
+ SELECT genres FROM ${TABLE}
160
+ WHERE anilist_id = CAST(shows_meta.id AS INTEGER)
161
+ AND genres IS NOT NULL AND genres != '[]'
162
+ LIMIT 1
163
+ )
164
+ WHERE (genres IS NULL OR genres = '' OR genres = '[]')
165
+ AND EXISTS (
166
+ SELECT 1 FROM ${TABLE}
167
+ WHERE anilist_id = CAST(shows_meta.id AS INTEGER)
168
+ AND genres IS NOT NULL AND genres != '[]'
169
+ )
170
+ `);
171
+ return 1;
172
+ }
173
+ catch (err) {
174
+ log.warn({ err }, 'Failed to backfill shows_meta genres from offline db');
175
+ return 0;
176
+ }
177
+ }
178
+ loadCache(db) {
179
+ try {
180
+ ensureTable(db);
181
+ const rows = db.all(`SELECT anilist_id, mal_id, title, thumbnail, type, genres FROM ${TABLE} WHERE anilist_id IS NOT NULL AND mal_id IS NOT NULL`);
182
+ this.malToEntry.clear();
183
+ this.anilistToMal.clear();
184
+ this.anilistToEntry.clear();
185
+ for (const row of rows) {
186
+ if (!row.anilist_id || !row.mal_id)
187
+ continue;
188
+ const entry = {
189
+ anilistId: row.anilist_id,
190
+ malId: row.mal_id,
191
+ title: row.title || '',
192
+ thumbnail: row.thumbnail || undefined,
193
+ type: row.type || undefined,
194
+ genres: row.genres || undefined,
195
+ };
196
+ this.malToEntry.set(row.mal_id, entry);
197
+ this.anilistToMal.set(row.anilist_id, row.mal_id);
198
+ this.anilistToEntry.set(row.anilist_id, entry);
199
+ }
200
+ }
201
+ catch (err) {
202
+ log.error({ err }, 'Failed to load offline mappings into memory');
203
+ }
204
+ }
205
+ getByMalId(malId) {
206
+ return this.malToEntry.get(malId) ?? null;
207
+ }
208
+ getByAnilistId(anilistId) {
209
+ return this.anilistToEntry.get(anilistId) ?? null;
210
+ }
211
+ getAnilistIdByMal(malId) {
212
+ return this.malToEntry.get(malId)?.anilistId ?? null;
213
+ }
214
+ getMalIdByAnilist(anilistId) {
215
+ return this.anilistToMal.get(anilistId) ?? null;
216
+ }
217
+ getOfflineDbInfo(db) {
218
+ const get = (key) => SettingsRepository.getByKey(db, key)?.value ?? null;
219
+ const autoUpdate = get('offlineDbAutoUpdateEnabled');
220
+ const lastStatus = get('offlineDbLastStatus');
221
+ const entryCount = Number(get('offlineDbEntryCount') ?? this.malToEntry.size) || 0;
222
+ return {
223
+ totalMapped: this.malToEntry.size,
224
+ totalMalMapped: this.malToEntry.size,
225
+ isInitialized: this.isInitialized,
226
+ isRefreshing: this.isRefreshing,
227
+ autoUpdateEnabled: autoUpdate ? autoUpdate !== 'false' : true,
228
+ lastCheckedAt: get('offlineDbLastCheckedAt'),
229
+ lastUpdatedAt: get('offlineDbLastUpdatedAt'),
230
+ lastStatus: lastStatus || (this.isRefreshing ? 'updating' : 'idle'),
231
+ lastMessage: get('offlineDbLastMessage'),
232
+ sourceUrl: get('offlineDbSourceUrl'),
233
+ releaseTag: get('offlineDbReleaseTag'),
234
+ contentSha256: get('offlineDbContentSha256'),
235
+ entryCount,
236
+ schemaVersion: SCHEMA_VERSION,
237
+ nextDueAt: latestSaturdayUtc().toISOString(),
238
+ };
239
+ }
240
+ checkWeeklyUpdateDue(db) {
241
+ const autoUpdate = SettingsRepository.getByKey(db, 'offlineDbAutoUpdateEnabled');
242
+ if (autoUpdate && autoUpdate.value === 'false')
243
+ return false;
244
+ const lastChecked = SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
245
+ if (!lastChecked?.value)
246
+ return true;
247
+ const lastCheckedTime = new Date(lastChecked.value).getTime();
248
+ if (Number.isNaN(lastCheckedTime))
249
+ return true;
250
+ return lastCheckedTime < latestSaturdayUtc().getTime();
251
+ }
252
+ interruptedRetryDue(db) {
253
+ const lastChecked = SettingsRepository.getByKey(db, 'offlineDbLastCheckedAt');
254
+ if (!lastChecked?.value)
255
+ return true;
256
+ const lastCheckedTime = new Date(lastChecked.value).getTime();
257
+ if (Number.isNaN(lastCheckedTime))
258
+ return true;
259
+ return Date.now() - lastCheckedTime > INTERRUPTED_RETRY_COOLDOWN_MS;
260
+ }
261
+ async executeScheduledUpdate(db) {
262
+ if (this.isRefreshing) {
263
+ log.info('Scheduled offline database update skipped: refresh already in progress');
264
+ return;
265
+ }
266
+ log.info('Starting scheduled weekly offline database update...');
267
+ const result = await this.refreshDatabase(db);
268
+ if (result.success)
269
+ log.info(`Scheduled offline database update succeeded: ${result.count} entries`);
270
+ else
271
+ log.warn(`Scheduled offline database update failed: ${result.error}`);
272
+ }
273
+ async refreshDatabase(db) {
274
+ if (this.isRefreshing) {
275
+ return { success: false, count: this.malToEntry.size, error: 'Refresh already in progress' };
276
+ }
277
+ this.isRefreshing = true;
278
+ const startedAt = Date.now();
279
+ SettingsRepository.upsert(db, 'offlineDbLastStatus', 'updating');
280
+ SettingsRepository.upsert(db, 'offlineDbLastCheckedAt', new Date().toISOString());
281
+ SettingsRepository.upsert(db, 'offlineDbLastMessage', 'Downloading and parsing database...');
282
+ try {
283
+ let text = null;
284
+ let sourceUrl = null;
285
+ let lastErr = null;
286
+ for (const url of OFFLINE_DB_URLS) {
287
+ try {
288
+ log.info(`Fetching anime-offline-database from ${url}...`);
289
+ text = await fetchText(url);
290
+ const parsed = JSON.parse(text);
291
+ if (parsed && Array.isArray(parsed.data)) {
292
+ sourceUrl = url;
293
+ break;
294
+ }
295
+ text = null;
296
+ }
297
+ catch (err) {
298
+ lastErr = err;
299
+ log.warn(`Failed downloading from ${url}: ${err.message}`);
300
+ }
301
+ }
302
+ if (!text)
303
+ throw lastErr || new Error('Could not download anime-offline-database from any source');
304
+ const sha256 = crypto.createHash('sha256').update(text).digest('hex');
305
+ const storedSha = SettingsRepository.getByKey(db, 'offlineDbContentSha256')?.value;
306
+ const data = JSON.parse(text).data;
307
+ log.info(`Parsing ${data.length} entries from anime-offline-database...`);
308
+ const anilistRegex = /anilist\.co\/anime\/(\d+)/i;
309
+ const malRegex = /myanimelist\.net\/anime\/(\d+)/i;
310
+ const parsedEntries = [];
311
+ for (const item of data) {
312
+ const rec = item;
313
+ if (!Array.isArray(rec.sources))
314
+ continue;
315
+ let anilistId = null;
316
+ let malId = null;
317
+ for (const src of rec.sources) {
318
+ if (typeof src !== 'string')
319
+ continue;
320
+ const anilistMatch = src.match(anilistRegex);
321
+ if (anilistMatch)
322
+ anilistId = parseInt(anilistMatch[1], 10);
323
+ const malMatch = src.match(malRegex);
324
+ if (malMatch)
325
+ malId = parseInt(malMatch[1], 10);
326
+ }
327
+ if (!anilistId || !malId)
328
+ continue;
329
+ let thumbnail = typeof rec.thumbnail === 'string'
330
+ ? rec.thumbnail
331
+ : typeof rec.picture === 'string'
332
+ ? rec.picture
333
+ : '';
334
+ if (thumbnail.includes('cdn.myanimelist.net/images/anime/')) {
335
+ thumbnail = thumbnail.replace(/t\.(jpe?g|png|webp)$/i, 'l.$1');
336
+ }
337
+ let genres;
338
+ if (Array.isArray(rec.tags)) {
339
+ const matched = new Set();
340
+ for (const tag of rec.tags) {
341
+ if (typeof tag !== 'string')
342
+ continue;
343
+ const canonical = canonicalGenreMap.get(tag.trim().toLowerCase());
344
+ if (canonical)
345
+ matched.add(canonical);
346
+ }
347
+ if (matched.size > 0)
348
+ genres = JSON.stringify([...matched]);
349
+ }
350
+ parsedEntries.push({
351
+ anilistId,
352
+ malId,
353
+ title: typeof rec.title === 'string' ? rec.title : '',
354
+ thumbnail: thumbnail || undefined,
355
+ type: typeof rec.type === 'string' ? rec.type : undefined,
356
+ genres,
357
+ });
358
+ }
359
+ log.info(`Saving ${parsedEntries.length} mapped entries to SQLite...`);
360
+ db.run(`DROP TABLE IF EXISTS ${STAGING_TABLE}`);
361
+ ensureTable(db, STAGING_TABLE);
362
+ const stmt = db.prepare(`INSERT INTO ${STAGING_TABLE} (anilist_id, mal_id, title, thumbnail, type, genres) VALUES (?, ?, ?, ?, ?, ?)`);
363
+ for (let i = 0; i < parsedEntries.length; i += CHUNK_SIZE) {
364
+ const chunk = parsedEntries.slice(i, i + CHUNK_SIZE);
365
+ db.serialize(() => {
366
+ for (const entry of chunk) {
367
+ stmt.run(entry.anilistId, entry.malId, entry.title, entry.thumbnail ?? null, entry.type ?? null, entry.genres ?? null);
368
+ }
369
+ });
370
+ await new Promise((resolve) => setImmediate(resolve));
371
+ }
372
+ db.run(`DROP TABLE IF EXISTS ${TABLE}`);
373
+ db.run(`ALTER TABLE ${STAGING_TABLE} RENAME TO ${TABLE}`);
374
+ ensureTable(db);
375
+ this.loadCache(db);
376
+ this.backfillShowsMetaGenres(db);
377
+ const releaseTag = sourceUrl?.includes('/releases/') ? 'latest' : 'raw';
378
+ SettingsRepository.upsert(db, 'offlineDbLastStatus', 'success');
379
+ SettingsRepository.upsert(db, 'offlineDbLastUpdatedAt', new Date().toISOString());
380
+ SettingsRepository.upsert(db, 'offlineDbSourceUrl', sourceUrl ?? '');
381
+ SettingsRepository.upsert(db, 'offlineDbReleaseTag', releaseTag);
382
+ SettingsRepository.upsert(db, 'offlineDbContentSha256', sha256);
383
+ SettingsRepository.upsert(db, 'offlineDbEntryCount', String(parsedEntries.length));
384
+ const skipped = storedSha === sha256;
385
+ SettingsRepository.upsert(db, 'offlineDbLastMessage', `Updated ${parsedEntries.length} entries in ${Math.round((Date.now() - startedAt) / 1000)}s${skipped ? ' (unchanged upstream hash)' : ''}.`);
386
+ log.info(`OfflineDb refresh completed: ${parsedEntries.length} entries from ${sourceUrl}`);
387
+ return { success: true, count: parsedEntries.length };
388
+ }
389
+ catch (err) {
390
+ const errMsg = err.message || 'Failed to refresh offline database';
391
+ log.error({ err }, 'Failed to refresh anime-offline-database');
392
+ SettingsRepository.upsert(db, 'offlineDbLastStatus', 'failed');
393
+ SettingsRepository.upsert(db, 'offlineDbLastMessage', errMsg);
394
+ return { success: false, count: this.malToEntry.size, error: errMsg };
395
+ }
396
+ finally {
397
+ this.isRefreshing = false;
398
+ }
399
+ }
400
+ }
401
+ export const offlineDb = new OfflineDb();
@@ -0,0 +1,3 @@
1
+ export const TEMP_SHOW_ID_PREFIX = 'dango-mt-';
2
+ export const isTempShowId = (id) => !!id && /^dango-mt-\d+$/.test(id);
3
+ export const isTempSyncRow = (row) => Object.values(row).some((v) => typeof v === 'string' && v.startsWith(TEMP_SHOW_ID_PREFIX));
@@ -0,0 +1,72 @@
1
+ import { parseJsonBody } from '../utils/http.utils.js';
2
+ const TMDB_BASE = 'https://api.themoviedb.org/3';
3
+ const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
4
+ const FALLBACK_TMDB_KEY = '9e7096a7575623aa30c66e9cc987e411';
5
+ const TMDB_KEYS = [
6
+ 'fb7bb23f03b6994dafc674c074d01761',
7
+ 'e55425032d3d0f371fc776f302e7c09b',
8
+ '8301a21598f8b45668d5711a814f01f6',
9
+ '8cf43ad9c085135b9479ad5cf6bbcbda',
10
+ 'da63548086e399ffc910fbc08526df05',
11
+ '13e53ff644a8bd4ba37b3e1044ad24f3',
12
+ '269890f657dddf4635473cf4cf456576',
13
+ 'a2f888b27315e62e471b2d587048f32e',
14
+ '8476a7ab80ad76f0936744df0430e67c',
15
+ '5622cafbfe8f8cfe358a29c53e19bba0',
16
+ 'ae4bd1b6fce2a5648671bfc171d15ba4',
17
+ '257654f35e3dff105574f97fb4b97035',
18
+ '2f4038e83265214a0dcd6ec2eb3276f5',
19
+ '9e43f45f94705cc8e1d5a0400d19a7b7',
20
+ 'af6887753365e14160254ac7f4345dd2',
21
+ '06f10fc8741a672af455421c239a1ffc',
22
+ '09ad8ace66eec34302943272db0e8d2c',
23
+ ];
24
+ let cachedKey = null;
25
+ export { TMDB_BASE, TMDB_IMAGE };
26
+ export async function getTmdbKey() {
27
+ if (!cachedKey) {
28
+ cachedKey =
29
+ process.env.TMDB_API_KEY ||
30
+ TMDB_KEYS[Math.floor(Math.random() * TMDB_KEYS.length)] ||
31
+ FALLBACK_TMDB_KEY;
32
+ }
33
+ return cachedKey;
34
+ }
35
+ export async function tmdbSearch(query) {
36
+ const key = await getTmdbKey();
37
+ const url = `${TMDB_BASE}/search/multi?api_key=${key}&query=${encodeURIComponent(query)}&include_adult=false`;
38
+ const res = await fetch(url);
39
+ if (!res.ok)
40
+ return null;
41
+ const json = await parseJsonBody(res);
42
+ return json.results ?? null;
43
+ }
44
+ export async function tmdbTvDetails(tmdbId) {
45
+ const key = await getTmdbKey();
46
+ const url = `${TMDB_BASE}/tv/${tmdbId}?api_key=${key}`;
47
+ const res = await fetch(url);
48
+ if (!res.ok)
49
+ return null;
50
+ const json = await parseJsonBody(res);
51
+ return json;
52
+ }
53
+ export async function findTmdbDefaultBackdrop(titleParts) {
54
+ const searchNames = [titleParts.english, titleParts.romaji, titleParts.native].filter(Boolean);
55
+ for (const name of searchNames) {
56
+ const results = await tmdbSearch(name);
57
+ if (!results)
58
+ continue;
59
+ const tvResults = results.filter((r) => r.media_type === 'tv');
60
+ if (tvResults.length === 0)
61
+ continue;
62
+ const bestMatch = tvResults.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))[0];
63
+ const details = await tmdbTvDetails(bestMatch.id);
64
+ if (!details?.backdrop_path)
65
+ continue;
66
+ return {
67
+ backdrop: `${TMDB_IMAGE}/original${details.backdrop_path}`,
68
+ overview: details.overview || bestMatch.overview || undefined,
69
+ };
70
+ }
71
+ return null;
72
+ }