@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,177 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { CONFIG } from './config.js';
5
+ export const LAN_AUTH_COOKIE = 'dango_lan_auth';
6
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
+ function sessionsFile() {
8
+ return path.join(CONFIG.ROOT, 'lan_sessions.json');
9
+ }
10
+ function loadSessions() {
11
+ try {
12
+ if (!fs.existsSync(sessionsFile()))
13
+ return {};
14
+ const raw = fs.readFileSync(sessionsFile(), 'utf8');
15
+ const parsed = JSON.parse(raw);
16
+ const now = Date.now();
17
+ let dirty = false;
18
+ for (const [token, expiry] of Object.entries(parsed)) {
19
+ if (typeof expiry !== 'number' || expiry < now) {
20
+ delete parsed[token];
21
+ dirty = true;
22
+ }
23
+ }
24
+ if (dirty)
25
+ saveSessions(parsed);
26
+ return parsed;
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function saveSessions(sessions) {
33
+ try {
34
+ fs.mkdirSync(CONFIG.ROOT, { recursive: true });
35
+ fs.writeFileSync(sessionsFile(), JSON.stringify(sessions));
36
+ }
37
+ catch {
38
+ // sessions won't be saved if disk write fails
39
+ }
40
+ }
41
+ export function getAppPasswordHash() {
42
+ return process.env.APP_PASSWORD_HASH || CONFIG.APP_PASSWORD_HASH || '';
43
+ }
44
+ export function hasAppPassword() {
45
+ return !!getAppPasswordHash();
46
+ }
47
+ export function hashAppPassword(password) {
48
+ const salt = crypto.randomBytes(16).toString('hex');
49
+ const hash = crypto.scryptSync(password, salt, 32).toString('hex');
50
+ return `scrypt:${salt}:${hash}`;
51
+ }
52
+ export function verifyAppPassword(password) {
53
+ const stored = getAppPasswordHash();
54
+ if (!stored)
55
+ return false;
56
+ const parts = stored.split(':');
57
+ if (parts.length !== 3 || parts[0] !== 'scrypt')
58
+ return false;
59
+ const [, salt, expectedHex] = parts;
60
+ try {
61
+ const derived = crypto.scryptSync(password, salt, 32);
62
+ const expected = Buffer.from(expectedHex, 'hex');
63
+ if (derived.length !== expected.length)
64
+ return false;
65
+ return crypto.timingSafeEqual(derived, expected);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export async function setAppPassword(password) {
72
+ const { updateEnvFile } = await import('./utils/env.utils.js');
73
+ if (!password) {
74
+ await updateEnvFile({ APP_PASSWORD_HASH: '' });
75
+ CONFIG.APP_PASSWORD_HASH = '';
76
+ return;
77
+ }
78
+ const hash = hashAppPassword(password);
79
+ await updateEnvFile({ APP_PASSWORD_HASH: hash });
80
+ CONFIG.APP_PASSWORD_HASH = hash;
81
+ }
82
+ export function createLanSession() {
83
+ const token = crypto.randomBytes(32).toString('hex');
84
+ const expiry = Date.now() + SESSION_TTL_MS;
85
+ const sessions = loadSessions();
86
+ sessions[token] = expiry;
87
+ saveSessions(sessions);
88
+ return { token, expiry };
89
+ }
90
+ export function validateLanSession(token) {
91
+ if (!token)
92
+ return false;
93
+ const sessions = loadSessions();
94
+ const expiry = sessions[token];
95
+ if (!expiry)
96
+ return false;
97
+ if (expiry < Date.now()) {
98
+ delete sessions[token];
99
+ saveSessions(sessions);
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ export function revokeLanSession(token) {
105
+ if (!token)
106
+ return;
107
+ const sessions = loadSessions();
108
+ if (sessions[token]) {
109
+ delete sessions[token];
110
+ saveSessions(sessions);
111
+ }
112
+ }
113
+ export function clearAllLanSessions() {
114
+ saveSessions({});
115
+ }
116
+ function normalizeIp(ip) {
117
+ if (!ip)
118
+ return '';
119
+ if (ip.startsWith('::ffff:'))
120
+ return ip.slice('::ffff:'.length);
121
+ return ip;
122
+ }
123
+ export function isLoopbackRequest(req) {
124
+ const candidates = [req.ip, req.socket?.remoteAddress].map((v) => normalizeIp(v));
125
+ return candidates.includes('127.0.0.1') || candidates.includes('::1');
126
+ }
127
+ export function getRequestToken(req) {
128
+ const auth = req.headers.authorization;
129
+ if (auth && auth.toLowerCase().startsWith('bearer ')) {
130
+ return auth.slice(7).trim() || null;
131
+ }
132
+ const cookieHeader = req.headers.cookie;
133
+ if (cookieHeader) {
134
+ const parts = cookieHeader.split(';');
135
+ for (const part of parts) {
136
+ const idx = part.indexOf('=');
137
+ if (idx === -1)
138
+ continue;
139
+ const name = part.slice(0, idx).trim();
140
+ if (name === LAN_AUTH_COOKIE) {
141
+ return decodeURIComponent(part.slice(idx + 1).trim()) || null;
142
+ }
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ export function isLanAuthenticated(req) {
148
+ if (!hasAppPassword())
149
+ return true;
150
+ return validateLanSession(getRequestToken(req));
151
+ }
152
+ const PUBLIC_PATHS = new Set([
153
+ '/api/auth/app-status',
154
+ '/api/auth/app-login',
155
+ '/api/auth/app-logout',
156
+ '/api/internal/shutdown',
157
+ ]);
158
+ export function lanAuthMiddleware(req, res, next) {
159
+ if (!req.path.startsWith('/api/'))
160
+ return next();
161
+ if (PUBLIC_PATHS.has(req.path))
162
+ return next();
163
+ if (req.path.startsWith('/api/internal/') && isLoopbackRequest(req))
164
+ return next();
165
+ if (!hasAppPassword())
166
+ return next();
167
+ if (validateLanSession(getRequestToken(req)))
168
+ return next();
169
+ return res.status(401).json({ error: 'LAN_AUTH_REQUIRED' });
170
+ }
171
+ export function buildLanCookie(token, expiry) {
172
+ const maxAge = Math.max(1, Math.floor((expiry - Date.now()) / 1000));
173
+ return `${LAN_AUTH_COOKIE}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
174
+ }
175
+ export function clearLanCookie() {
176
+ return `${LAN_AUTH_COOKIE}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`;
177
+ }
@@ -0,0 +1,175 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import dotenv from 'dotenv';
5
+ import { SHIPPED_DEFAULTS } from './shipped-defaults.js';
6
+ export const SERVER_ROOT = path.resolve(import.meta.dirname, '..');
7
+ const PACKAGE_ROOT = path.resolve(SERVER_ROOT, '..');
8
+ function resolveDataRoot() {
9
+ if (process.platform === 'win32' && process.env.APPDATA) {
10
+ return path.join(process.env.APPDATA, 'dango');
11
+ }
12
+ if (process.platform === 'darwin') {
13
+ return path.join(os.homedir(), 'Library', 'Application Support', 'dango');
14
+ }
15
+ if (process.env.XDG_DATA_HOME) {
16
+ return path.join(process.env.XDG_DATA_HOME, 'dango');
17
+ }
18
+ return path.join(os.homedir(), '.local', 'share', 'dango');
19
+ }
20
+ function moveFileIfNeeded(sourcePath, destinationPath) {
21
+ if (!fs.existsSync(sourcePath) || fs.existsSync(destinationPath)) {
22
+ return;
23
+ }
24
+ try {
25
+ fs.renameSync(sourcePath, destinationPath);
26
+ }
27
+ catch {
28
+ fs.copyFileSync(sourcePath, destinationPath);
29
+ fs.unlinkSync(sourcePath);
30
+ }
31
+ }
32
+ function migrateLegacyData(packageServerRoot, dataRoot) {
33
+ const legacyFiles = [
34
+ '.env',
35
+ 'google_tokens.json',
36
+ 'sync_manifest.json',
37
+ 'sync_manifest.dev.json',
38
+ 'anime.db',
39
+ 'anime.db-shm',
40
+ 'anime.db-wal',
41
+ 'anime.dev.db',
42
+ 'anime.dev.db-shm',
43
+ 'anime.dev.db-wal',
44
+ ];
45
+ fs.mkdirSync(dataRoot, { recursive: true });
46
+ for (const filename of legacyFiles) {
47
+ moveFileIfNeeded(path.join(packageServerRoot, filename), path.join(dataRoot, filename));
48
+ }
49
+ // Manga library database + manifests (added after anime DB; migrate if present)
50
+ for (const filename of [
51
+ 'manga.db',
52
+ 'manga.db-shm',
53
+ 'manga.db-wal',
54
+ 'manga.dev.db',
55
+ 'manga.dev.db-shm',
56
+ 'manga.dev.db-wal',
57
+ 'manga.sync_manifest.json',
58
+ 'manga.sync_manifest.dev.json',
59
+ 'tv.db',
60
+ 'tv.db-shm',
61
+ 'tv.db-wal',
62
+ 'tv.dev.db',
63
+ 'tv.dev.db-shm',
64
+ 'tv.dev.db-wal',
65
+ 'tv.sync_manifest.json',
66
+ 'tv.sync_manifest.dev.json',
67
+ 'asmr.db',
68
+ 'asmr.db-shm',
69
+ 'asmr.db-wal',
70
+ 'asmr.dev.db',
71
+ 'asmr.dev.db-shm',
72
+ 'asmr.dev.db-wal',
73
+ 'asmr.sync_manifest.json',
74
+ 'asmr.sync_manifest.dev.json',
75
+ ]) {
76
+ moveFileIfNeeded(path.join(packageServerRoot, filename), path.join(dataRoot, filename));
77
+ }
78
+ }
79
+ function resolveLegacyDataRoot() {
80
+ if (process.platform === 'win32' && process.env.APPDATA) {
81
+ return path.join(process.env.APPDATA, 'ani-web');
82
+ }
83
+ if (process.platform === 'darwin') {
84
+ return path.join(os.homedir(), 'Library', 'Application Support', 'ani-web');
85
+ }
86
+ if (process.env.XDG_DATA_HOME) {
87
+ return path.join(process.env.XDG_DATA_HOME, 'ani-web');
88
+ }
89
+ return path.join(os.homedir(), '.local', 'share', 'ani-web');
90
+ }
91
+ function migrateFromAniWeb(dataRoot) {
92
+ const legacyRoot = resolveLegacyDataRoot();
93
+ if (legacyRoot === dataRoot)
94
+ return;
95
+ if (!fs.existsSync(legacyRoot))
96
+ return;
97
+ const filesToMigrate = [
98
+ '.env',
99
+ 'google_tokens.json',
100
+ 'sync_manifest.json',
101
+ 'sync_manifest.dev.json',
102
+ 'anime.db',
103
+ 'anime.db-shm',
104
+ 'anime.db-wal',
105
+ 'anime.dev.db',
106
+ 'anime.dev.db-shm',
107
+ 'anime.dev.db-wal',
108
+ ];
109
+ fs.mkdirSync(dataRoot, { recursive: true });
110
+ for (const filename of filesToMigrate) {
111
+ moveFileIfNeeded(path.join(legacyRoot, filename), path.join(dataRoot, filename));
112
+ }
113
+ }
114
+ const DATA_ROOT = resolveDataRoot();
115
+ const ENV_PATH = path.join(DATA_ROOT, '.env');
116
+ migrateLegacyData(SERVER_ROOT, DATA_ROOT);
117
+ migrateFromAniWeb(DATA_ROOT);
118
+ dotenv.config({ path: path.join(SERVER_ROOT, '.env') });
119
+ dotenv.config({ path: ENV_PATH, override: true });
120
+ const IS_DEV = process.argv.includes('--dev');
121
+ const PORT = 3000;
122
+ const HOST = process.env.HOST || '0.0.0.0';
123
+ const GOOGLE_REDIRECT_URI = IS_DEV
124
+ ? 'http://localhost:5173/api/auth/google/callback'
125
+ : `http://localhost:${PORT}/api/auth/google/callback`;
126
+ export const CONFIG = {
127
+ ROOT: DATA_ROOT,
128
+ SERVER_ROOT,
129
+ PACKAGE_ROOT,
130
+ ENV_PATH,
131
+ TOKEN_PATH: path.join(DATA_ROOT, 'google_tokens.json'),
132
+ LOCAL_MANIFEST_PATH: path.join(DATA_ROOT, IS_DEV ? 'sync_manifest.dev.json' : 'sync_manifest.json'),
133
+ MANGA_LOCAL_MANIFEST_PATH: path.join(DATA_ROOT, IS_DEV ? 'manga.sync_manifest.dev.json' : 'manga.sync_manifest.json'),
134
+ TV_LOCAL_MANIFEST_PATH: path.join(DATA_ROOT, IS_DEV ? 'tv.sync_manifest.dev.json' : 'tv.sync_manifest.json'),
135
+ ASMR_LOCAL_MANIFEST_PATH: path.join(DATA_ROOT, IS_DEV ? 'asmr.sync_manifest.dev.json' : 'asmr.sync_manifest.json'),
136
+ DB_NAME_PROD: 'anime.db',
137
+ DB_NAME_DEV: 'anime.dev.db',
138
+ MANGA_DB_NAME_PROD: 'manga.db',
139
+ MANGA_DB_NAME_DEV: 'manga.dev.db',
140
+ TV_DB_NAME_PROD: 'tv.db',
141
+ TV_DB_NAME_DEV: 'tv.dev.db',
142
+ ASMR_DB_NAME_PROD: 'asmr.db',
143
+ ASMR_DB_NAME_DEV: 'asmr.dev.db',
144
+ REMOTE_FOLDER_PROD: 'dango_db',
145
+ REMOTE_FOLDER_DEV: 'dango_dev_db',
146
+ MANIFEST_FILENAME: IS_DEV ? 'sync_manifest.dev.json' : 'sync_manifest.json',
147
+ GOOGLE_SCOPES: [
148
+ 'https://www.googleapis.com/auth/drive.appdata',
149
+ 'https://www.googleapis.com/auth/drive.file',
150
+ 'https://www.googleapis.com/auth/userinfo.email',
151
+ ],
152
+ GOOGLE_SYNC_FILENAME: IS_DEV ? 'sync.dev.json' : 'sync.json',
153
+ MANGA_GOOGLE_SYNC_FILENAME: IS_DEV ? 'manga.sync.dev.json' : 'manga.sync.json',
154
+ TV_GOOGLE_SYNC_FILENAME: IS_DEV ? 'tv.sync.dev.json' : 'tv.sync.json',
155
+ ASMR_GOOGLE_SYNC_FILENAME: IS_DEV ? 'asmr.sync.dev.json' : 'asmr.sync.json',
156
+ RCLONE_SYNC_FILENAME: IS_DEV ? 'sync.dev.json' : 'sync.json',
157
+ MANGA_RCLONE_SYNC_FILENAME: IS_DEV ? 'manga.sync.dev.json' : 'manga.sync.json',
158
+ TV_RCLONE_SYNC_FILENAME: IS_DEV ? 'tv.sync.dev.json' : 'tv.sync.json',
159
+ ASMR_RCLONE_SYNC_FILENAME: IS_DEV ? 'asmr.sync.dev.json' : 'asmr.sync.json',
160
+ IS_DEV,
161
+ PORT,
162
+ HOST,
163
+ GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
164
+ GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
165
+ GOOGLE_REDIRECT_URI: GOOGLE_REDIRECT_URI,
166
+ GOOGLE_AUTH_WORKER_URL: process.env.GOOGLE_AUTH_WORKER_URL || SHIPPED_DEFAULTS.GOOGLE_AUTH_WORKER_URL || '',
167
+ RCLONE_REMOTE: process.env.RCLONE_REMOTE,
168
+ SYNC_PROVIDER: process.env.SYNC_PROVIDER,
169
+ DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || SHIPPED_DEFAULTS.DISCORD_CLIENT_ID || undefined,
170
+ DISCORD_ROLES_WORKER_URL: process.env.DISCORD_ROLES_WORKER_URL || SHIPPED_DEFAULTS.DISCORD_ROLES_WORKER_URL || '',
171
+ APP_PASSWORD_HASH: process.env.APP_PASSWORD_HASH || '',
172
+ PROVIDER_REPO_URL: process.env.PROVIDER_REPO_URL || SHIPPED_DEFAULTS.PROVIDER_REPO_URL || '',
173
+ PROVIDER_ENABLED: process.env.PROVIDER_ENABLED || '',
174
+ PROVIDER_REPO_REFRESH_MS: parseInt(process.env.PROVIDER_REPO_REFRESH_MS || '', 10) || 21600000,
175
+ };
@@ -0,0 +1,288 @@
1
+ import logger from '../logger.js';
2
+ import { performAsmrWriteTransaction } from '../sync.js';
3
+ import { dbAll } from '../utils/db-utils.js';
4
+ import { SettingsRepository } from '../repositories/settings.repository.js';
5
+ import { buildAsmrId, ASMR_STATUSES, AsmrLibraryRepository, AsmrProgressRepository, } from '../repositories/asmr.repository.js';
6
+ function asmrDb(req) {
7
+ const db = req.asmrDb;
8
+ if (!db || db.isClosedCheck())
9
+ throw new Error('ASMR database is not ready');
10
+ return db;
11
+ }
12
+ export class AsmrLibraryController {
13
+ getLibrary = async (req, res) => {
14
+ try {
15
+ const { status, page: pageStr, limit: limitStr } = req.query;
16
+ const page = Math.max(parseInt(pageStr) || 1, 1);
17
+ const limit = Math.min(Math.max(parseInt(limitStr) || 24, 1), 100);
18
+ const offset = (page - 1) * limit;
19
+ const db = asmrDb(req);
20
+ const rows = AsmrLibraryRepository.getAll(db, status, limit, offset);
21
+ const total = AsmrLibraryRepository.getCount(db, status);
22
+ res.json({ data: rows, total, page, limit });
23
+ }
24
+ catch (err) {
25
+ logger.error({ err }, 'Failed to get ASMR library');
26
+ res.status(500).json({ error: 'Failed to load listening list' });
27
+ }
28
+ };
29
+ checkLibrary = async (req, res) => {
30
+ try {
31
+ const item = AsmrLibraryRepository.getById(asmrDb(req), req.params.id);
32
+ res.json({ inLibrary: !!item, status: item?.status ?? null });
33
+ }
34
+ catch {
35
+ res.json({ inLibrary: false, status: null });
36
+ }
37
+ };
38
+ getLibraryIds = async (req, res) => {
39
+ try {
40
+ res.json({ ids: AsmrLibraryRepository.getIds(asmrDb(req)) });
41
+ }
42
+ catch {
43
+ res.json({ ids: [] });
44
+ }
45
+ };
46
+ addToLibrary = async (req, res) => {
47
+ const { rjCode, id: idRaw, title, thumbnail, status, isAdult } = req.body ?? {};
48
+ const rawCode = String(rjCode || idRaw || '').trim();
49
+ if (!rawCode) {
50
+ return res.status(400).json({ error: 'rjCode is required' });
51
+ }
52
+ const id = idRaw && String(idRaw).trim() ? buildAsmrId(String(idRaw)) : buildAsmrId(rawCode);
53
+ const cleanStatus = typeof status === 'string' && ASMR_STATUSES.includes(status)
54
+ ? status
55
+ : 'Listening';
56
+ try {
57
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
58
+ AsmrLibraryRepository.upsert(tx, {
59
+ id,
60
+ rjCode: buildAsmrId(rawCode),
61
+ title: String(title || ''),
62
+ thumbnail: String(thumbnail || ''),
63
+ status: cleanStatus,
64
+ isAdult: isAdult === true || isAdult === 1,
65
+ });
66
+ });
67
+ res.json({ success: true, id });
68
+ }
69
+ catch (err) {
70
+ logger.error({ err }, 'Failed to add ASMR work to library');
71
+ res.status(500).json({ error: 'Failed to add to listening list' });
72
+ }
73
+ };
74
+ removeFromLibrary = async (req, res) => {
75
+ const { id } = req.body ?? {};
76
+ if (!id)
77
+ return res.status(400).json({ error: 'id is required' });
78
+ try {
79
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
80
+ AsmrLibraryRepository.delete(tx, String(id));
81
+ AsmrProgressRepository.deleteByWork(tx, String(id));
82
+ });
83
+ res.json({ success: true });
84
+ }
85
+ catch (err) {
86
+ logger.error({ err }, 'Failed to remove ASMR work from library');
87
+ res.status(500).json({ error: 'Failed to remove from listening list' });
88
+ }
89
+ };
90
+ updateStatus = async (req, res) => {
91
+ const { id, status } = req.body ?? {};
92
+ if (!id || !ASMR_STATUSES.includes(status)) {
93
+ return res.status(400).json({ error: 'id and a valid status are required' });
94
+ }
95
+ try {
96
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
97
+ AsmrLibraryRepository.updateStatus(tx, String(id), String(status));
98
+ });
99
+ res.json({ success: true });
100
+ }
101
+ catch (err) {
102
+ logger.error({ err }, 'Failed to update ASMR status');
103
+ res.status(500).json({ error: 'Failed to update status' });
104
+ }
105
+ };
106
+ getProgress = async (req, res) => {
107
+ try {
108
+ const rows = AsmrProgressRepository.getByWork(asmrDb(req), req.params.workId);
109
+ res.json({ progress: rows });
110
+ }
111
+ catch {
112
+ res.json({ progress: [] });
113
+ }
114
+ };
115
+ getLatestProgress = async (req, res) => {
116
+ try {
117
+ const row = AsmrProgressRepository.getLatest(asmrDb(req), req.params.workId);
118
+ res.json(row || { currentTime: 0, duration: 0 });
119
+ }
120
+ catch {
121
+ res.json({ currentTime: 0, duration: 0 });
122
+ }
123
+ };
124
+ saveProgress = async (req, res) => {
125
+ const { workId, trackIndex, trackLabel, currentTime, duration, title, thumbnail, rjCode, isAdult, } = req.body ?? {};
126
+ if (!workId) {
127
+ return res.status(400).json({ error: 'workId is required' });
128
+ }
129
+ const trackIndexNum = Math.max(Math.round(Number(trackIndex)) || 0, 0);
130
+ const timeNum = Math.max(Number(currentTime) || 0, 0);
131
+ const durationNum = Math.max(Number(duration) || 0, 0);
132
+ try {
133
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
134
+ AsmrProgressRepository.upsert(tx, {
135
+ workId: String(workId),
136
+ trackIndex: trackIndexNum,
137
+ trackLabel: String(trackLabel || ''),
138
+ currentTime: timeNum,
139
+ duration: durationNum,
140
+ title: title ?? null,
141
+ thumbnail: thumbnail ?? null,
142
+ rjCode: rjCode ?? null,
143
+ isAdult: isAdult != null ? Number(isAdult) : null,
144
+ });
145
+ AsmrLibraryRepository.touchProgress(tx, String(workId), {
146
+ trackIndex: trackIndexNum,
147
+ trackLabel: String(trackLabel || ''),
148
+ position: timeNum,
149
+ });
150
+ });
151
+ res.json({ success: true });
152
+ }
153
+ catch (err) {
154
+ logger.error({ err }, 'Failed to save ASMR progress');
155
+ res.status(500).json({ error: 'Failed to save progress' });
156
+ }
157
+ };
158
+ getContinueListening = async (req, res) => {
159
+ try {
160
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 24, 1), 100);
161
+ const ignoreAdultRow = await SettingsRepository.getByKey(req.db, 'asmrIgnoreAdultContent');
162
+ const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
163
+ const listOnlyRow = await SettingsRepository.getByKey(req.db, 'asmrCwWatchlistOnly');
164
+ const listOnly = listOnlyRow
165
+ ? listOnlyRow.value === 'true' || listOnlyRow.value === '1'
166
+ : false;
167
+ const rows = AsmrProgressRepository.getContinueListening(asmrDb(req), limit).filter((row) => {
168
+ if (ignoreAdult && row.isAdult === 1)
169
+ return false;
170
+ if (listOnly && row.watchlistStatus !== 'Listening')
171
+ return false;
172
+ return true;
173
+ });
174
+ res.json({ data: rows, total: rows.length });
175
+ }
176
+ catch {
177
+ res.json({ data: [], total: 0 });
178
+ }
179
+ };
180
+ async getAdultNonListWorkIds(db) {
181
+ const rows = await dbAll(db, `SELECT DISTINCT p.workId as workId
182
+ FROM asmr_progress p
183
+ LEFT JOIN asmr_library l ON l.id = p.workId
184
+ WHERE COALESCE(l.isAdult, p.isAdult) = 1
185
+ AND l.id IS NULL`);
186
+ return rows.map((r) => r.workId);
187
+ }
188
+ getAdultContinueListeningCount = async (req, res) => {
189
+ try {
190
+ const ids = await this.getAdultNonListWorkIds(asmrDb(req));
191
+ res.json({ count: ids.length });
192
+ }
193
+ catch {
194
+ res.json({ count: 0 });
195
+ }
196
+ };
197
+ purgeAdultContinueListening = async (req, res) => {
198
+ try {
199
+ const ids = await this.getAdultNonListWorkIds(asmrDb(req));
200
+ if (ids.length > 0) {
201
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
202
+ for (const id of ids)
203
+ AsmrProgressRepository.deleteByWork(tx, id);
204
+ });
205
+ }
206
+ res.json({ success: true, removed: ids.length });
207
+ }
208
+ catch (err) {
209
+ logger.error({ err }, 'Failed to purge adult ASMR progress');
210
+ res.status(500).json({ error: 'Failed to purge adult entries' });
211
+ }
212
+ };
213
+ removeProgress = async (req, res) => {
214
+ const { workId, trackIndex } = req.body ?? {};
215
+ if (!workId)
216
+ return res.status(400).json({ error: 'workId is required' });
217
+ try {
218
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
219
+ if (trackIndex !== undefined && trackIndex !== null) {
220
+ AsmrProgressRepository.deleteTrack(tx, String(workId), Number(trackIndex));
221
+ }
222
+ else {
223
+ AsmrProgressRepository.deleteByWork(tx, String(workId));
224
+ }
225
+ });
226
+ res.json({ success: true });
227
+ }
228
+ catch (err) {
229
+ logger.error({ err }, 'Failed to remove ASMR progress');
230
+ res.status(500).json({ error: 'Failed to reset progress' });
231
+ }
232
+ };
233
+ batchUpdateStatus = async (req, res) => {
234
+ const { ids: idsRaw, status } = req.body ?? {};
235
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
236
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
237
+ }
238
+ if (!status || !ASMR_STATUSES.includes(status)) {
239
+ return res.status(400).json({ error: 'a valid status is required' });
240
+ }
241
+ const ids = idsRaw.map((id) => String(id));
242
+ try {
243
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
244
+ AsmrLibraryRepository.updateStatusMany(tx, ids, String(status));
245
+ });
246
+ res.json({ success: true, updated: ids.length });
247
+ }
248
+ catch (err) {
249
+ logger.error({ err }, 'Failed to batch update ASMR status');
250
+ res.status(500).json({ error: 'Failed to update statuses' });
251
+ }
252
+ };
253
+ batchRemove = async (req, res) => {
254
+ const { ids: idsRaw } = req.body ?? {};
255
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
256
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
257
+ }
258
+ const ids = idsRaw.map((id) => String(id));
259
+ try {
260
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
261
+ AsmrLibraryRepository.deleteMany(tx, ids);
262
+ AsmrProgressRepository.deleteMany(tx, ids);
263
+ });
264
+ res.json({ success: true, removed: ids.length });
265
+ }
266
+ catch (err) {
267
+ logger.error({ err }, 'Failed to batch remove ASMR works');
268
+ res.status(500).json({ error: 'Failed to remove from listening list' });
269
+ }
270
+ };
271
+ batchRemoveProgress = async (req, res) => {
272
+ const { ids: idsRaw } = req.body ?? {};
273
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
274
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
275
+ }
276
+ const ids = idsRaw.map((id) => String(id));
277
+ try {
278
+ await performAsmrWriteTransaction(asmrDb(req), (tx) => {
279
+ AsmrProgressRepository.deleteMany(tx, ids);
280
+ });
281
+ res.json({ success: true, removed: ids.length });
282
+ }
283
+ catch (err) {
284
+ logger.error({ err }, 'Failed to batch remove ASMR progress');
285
+ res.status(500).json({ error: 'Failed to reset progress' });
286
+ }
287
+ };
288
+ }