@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,225 @@
1
+ export function normalizeCompact(s) {
2
+ return s.toLowerCase().replace(/[^a-z0-9]/g, '');
3
+ }
4
+ export function tokenize(s) {
5
+ return s
6
+ .toLowerCase()
7
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
8
+ .split(/\s+/)
9
+ .filter((w) => w.length >= 2);
10
+ }
11
+ export function bigramDice(a, b) {
12
+ if (a.length < 2 || b.length < 2)
13
+ return a === b ? 1 : 0;
14
+ const grams = (s) => {
15
+ const map = new Map();
16
+ for (let i = 0; i < s.length - 1; i++) {
17
+ const g = s.slice(i, i + 2);
18
+ map.set(g, (map.get(g) || 0) + 1);
19
+ }
20
+ return map;
21
+ };
22
+ const A = grams(a);
23
+ const B = grams(b);
24
+ let totalA = 0;
25
+ let totalB = 0;
26
+ let shared = 0;
27
+ A.forEach((c) => (totalA += c));
28
+ B.forEach((c) => (totalB += c));
29
+ A.forEach((c, g) => {
30
+ const other = B.get(g);
31
+ if (other)
32
+ shared += Math.min(c, other);
33
+ });
34
+ return (2 * shared) / (totalA + totalB);
35
+ }
36
+ const STOPWORDS = new Set([
37
+ 'the',
38
+ 'animation',
39
+ 'no',
40
+ 'wa',
41
+ 'ga',
42
+ 'wo',
43
+ 'ni',
44
+ 'de',
45
+ 'to',
46
+ 'e',
47
+ 'o',
48
+ 'x',
49
+ 'ii',
50
+ 'yo',
51
+ 'ova',
52
+ 'oad',
53
+ 'hd',
54
+ 'ep',
55
+ 'episode',
56
+ 'season',
57
+ 'part',
58
+ 'hen',
59
+ 'a',
60
+ ]);
61
+ const SEASON_TOKENS = new Set([
62
+ 'season',
63
+ '2nd',
64
+ '3rd',
65
+ '4th',
66
+ '5th',
67
+ 'ii',
68
+ 'iii',
69
+ 'iv',
70
+ 'v',
71
+ 'vi',
72
+ 'vii',
73
+ 'viii',
74
+ 'ix',
75
+ 'x',
76
+ 'two',
77
+ 'three',
78
+ 'four',
79
+ 'five',
80
+ 'six',
81
+ 'seven',
82
+ 'eight',
83
+ 'nine',
84
+ 'ten',
85
+ ]);
86
+ function distinctiveTokensExcludingSeason(s) {
87
+ return [...new Set(tokenize(s).filter((w) => !STOPWORDS.has(w) && !SEASON_TOKENS.has(w)))];
88
+ }
89
+ const ROMAN_TO_NUM = {
90
+ ii: 2,
91
+ iii: 3,
92
+ iv: 4,
93
+ v: 5,
94
+ vi: 6,
95
+ vii: 7,
96
+ viii: 8,
97
+ ix: 9,
98
+ x: 10,
99
+ };
100
+ const WORD_TO_NUM = {
101
+ two: 2,
102
+ three: 3,
103
+ four: 4,
104
+ five: 5,
105
+ six: 6,
106
+ seven: 7,
107
+ eight: 8,
108
+ nine: 9,
109
+ ten: 10,
110
+ };
111
+ function detectSeasonMarker(s) {
112
+ const t = s.toLowerCase();
113
+ // "season 2", "season ii", "season 2nd", "season three"
114
+ const seasonNum = t.match(/\bseason\s*(\d+|ii|iii|iv|v|vi|vii|viii|ix|x|two|three|four|five|six|seven|eight|nine|ten)\b/);
115
+ if (seasonNum) {
116
+ const token = seasonNum[1];
117
+ const n = ROMAN_TO_NUM[token];
118
+ const w = WORD_TO_NUM[token];
119
+ return String(n ?? w ?? token);
120
+ }
121
+ const ord = t.match(/\b(\d+)(st|nd|rd|th)\b/);
122
+ if (ord)
123
+ return ord[1];
124
+ const trailing = t.match(/\b(ii|iii|iv|v|vi|vii|viii|ix|x)\b(?!\s*(st|nd|rd|th|season))/);
125
+ if (trailing)
126
+ return String(ROMAN_TO_NUM[trailing[1]]);
127
+ // bare trailing number, e.g. "...Another World 2"
128
+ const trailingNum = t.match(/\b(\d+)\s*(season)?\s*$/);
129
+ if (trailingNum)
130
+ return trailingNum[1];
131
+ return '';
132
+ }
133
+ export function distinctiveTokens(s) {
134
+ return [...new Set(tokenize(s).filter((w) => !STOPWORDS.has(w)))];
135
+ }
136
+ export function titleSimilarity(query, candidate) {
137
+ const q = normalizeCompact(query);
138
+ const c = normalizeCompact(candidate);
139
+ if (!q || !c)
140
+ return 0;
141
+ if (q === c)
142
+ return 1;
143
+ let score = bigramDice(q, c);
144
+ if (q.startsWith(c) || c.startsWith(q)) {
145
+ const prefixScore = 0.5 + 0.5 * (Math.min(q.length, c.length) / Math.max(q.length, c.length));
146
+ if (prefixScore > score)
147
+ score = prefixScore;
148
+ }
149
+ const qD = distinctiveTokensExcludingSeason(query);
150
+ const cD = distinctiveTokensExcludingSeason(candidate);
151
+ if (qD.length > 0 && cD.length >= 2) {
152
+ const cSet = new Set(cD);
153
+ let hits = 0;
154
+ for (const t of qD) {
155
+ if (cSet.has(t))
156
+ hits++;
157
+ }
158
+ const coverage = hits / Math.min(qD.length, cD.length);
159
+ if (coverage > score)
160
+ score = coverage;
161
+ }
162
+ const qMarker = detectSeasonMarker(query);
163
+ if (qMarker) {
164
+ const cMarker = detectSeasonMarker(candidate);
165
+ if (cMarker === qMarker) {
166
+ score += 0.25;
167
+ }
168
+ else if (!cMarker && score >= 0.5) {
169
+ score -= 0.25;
170
+ }
171
+ }
172
+ return score >= 1 ? 0.99 : score;
173
+ }
174
+ export function pickBestMatch(candidates, targets, minScore = 0.6) {
175
+ if (!candidates.length)
176
+ return null;
177
+ const cleanTargets = targets.filter((t) => !!t && t.trim().length > 0);
178
+ if (!cleanTargets.length)
179
+ return null;
180
+ let best = null;
181
+ for (const item of candidates) {
182
+ if (!item.title)
183
+ continue;
184
+ let score = 0;
185
+ for (const target of cleanTargets) {
186
+ const s = titleSimilarity(target, item.title);
187
+ if (s > score)
188
+ score = s;
189
+ }
190
+ if (!best || score > best.score)
191
+ best = { item, score };
192
+ }
193
+ return best && best.score >= minScore ? best : null;
194
+ }
195
+ export function buildQueryVariants(title, romaji) {
196
+ const seen = new Set();
197
+ const out = [];
198
+ const push = (q) => {
199
+ const v = q.replace(/\s+/g, ' ').trim();
200
+ if (v && !seen.has(v.toLowerCase())) {
201
+ seen.add(v.toLowerCase());
202
+ out.push(v);
203
+ }
204
+ };
205
+ if (romaji && romaji.trim())
206
+ push(romaji);
207
+ push(title);
208
+ const stripped = (s) => s.replace(/[^\p{L}\p{N}\s]/gu, ' ');
209
+ push(stripped(title));
210
+ if (romaji)
211
+ push(stripped(romaji));
212
+ const words = stripped(title).split(/\s+/).filter(Boolean);
213
+ if (words.length > 3)
214
+ push(words.slice(0, 3).join(' '));
215
+ if (words.length > 4)
216
+ push(words.slice(0, 4).join(' '));
217
+ const tokens = distinctiveTokens(title)
218
+ .concat(romaji ? distinctiveTokens(romaji) : [])
219
+ .filter((w) => w.length >= 4);
220
+ const unique = [...new Set(tokens)];
221
+ unique.sort((a, b) => b.length - a.length);
222
+ for (const t of unique.slice(0, 4))
223
+ push(t);
224
+ return out;
225
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { spawn, execFile } from 'child_process';
2
+ import logger from './logger.js';
3
+ import { CONFIG } from './config.js';
4
+ class RcloneService {
5
+ activeRemote = null;
6
+ executeCommand(args) {
7
+ return new Promise((resolve, reject) => {
8
+ execFile('rclone', args, (err, stdout, stderr) => {
9
+ if (err) {
10
+ if (stderr)
11
+ logger.warn({ stderr }, 'Rclone command warning');
12
+ return reject(new Error(stderr || err.message));
13
+ }
14
+ resolve(stdout.trim());
15
+ });
16
+ });
17
+ }
18
+ executeRcloneArgs(args) {
19
+ return new Promise((resolve, reject) => {
20
+ const process = spawn('rclone', args, { stdio: 'ignore' });
21
+ process.on('close', (code) => {
22
+ if (code === 0)
23
+ resolve();
24
+ else
25
+ reject(new Error(`Rclone exited with code ${code}`));
26
+ });
27
+ process.on('error', (err) => reject(err));
28
+ });
29
+ }
30
+ async listRemotes() {
31
+ try {
32
+ const remotesStr = await this.executeCommand(['listremotes']);
33
+ return remotesStr
34
+ .split('\n')
35
+ .map((r) => r.trim())
36
+ .filter((r) => r !== '')
37
+ .map((r) => r.replace(/:$/, ''));
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ async init() {
44
+ try {
45
+ await this.executeCommand(['version']);
46
+ const remotes = await this.listRemotes();
47
+ if (CONFIG.RCLONE_REMOTE) {
48
+ const found = remotes.find((r) => r.toLowerCase() === CONFIG.RCLONE_REMOTE?.toLowerCase());
49
+ if (found) {
50
+ this.activeRemote = found;
51
+ logger.info(`Rclone initialized with manual remote: ${this.activeRemote}`);
52
+ return true;
53
+ }
54
+ else {
55
+ logger.warn(`Configured RCLONE_REMOTE '${CONFIG.RCLONE_REMOTE}' not found in rclone listremotes.`);
56
+ }
57
+ }
58
+ if (remotes.length > 0 && !CONFIG.RCLONE_REMOTE) {
59
+ logger.info({ remotes }, 'Rclone available but no manual remote is configured in settings.');
60
+ return false;
61
+ }
62
+ return false;
63
+ }
64
+ catch (error) {
65
+ logger.warn({ err: error }, 'Rclone initialization failed');
66
+ return false;
67
+ }
68
+ }
69
+ isActive() {
70
+ return this.activeRemote !== null;
71
+ }
72
+ getRemoteName() {
73
+ return this.activeRemote || 'unknown';
74
+ }
75
+ async downloadFile(remoteFolder, fileName, localPath) {
76
+ if (!this.activeRemote)
77
+ throw new Error('Rclone not active');
78
+ const remotePath = `${this.activeRemote}:${remoteFolder}/${fileName}`;
79
+ await this.executeRcloneArgs(['copyto', remotePath, localPath]);
80
+ }
81
+ async uploadFile(localPath, remoteFolder, fileName) {
82
+ if (!this.activeRemote)
83
+ throw new Error('Rclone not active');
84
+ const remotePath = `${this.activeRemote}:${remoteFolder}/${fileName}`;
85
+ await this.executeRcloneArgs(['copyto', localPath, remotePath]);
86
+ }
87
+ executeRcloneArgsWithOutput(args) {
88
+ return new Promise((resolve, reject) => {
89
+ const process = spawn('rclone', args, { stdio: 'pipe' });
90
+ let stdout = '';
91
+ let stderr = '';
92
+ process.stdout?.on('data', (data) => (stdout += data));
93
+ process.stderr?.on('data', (data) => (stderr += data));
94
+ process.on('close', (code) => {
95
+ if (code === 0)
96
+ resolve(stdout.trim());
97
+ else {
98
+ if (stderr)
99
+ logger.warn({ stderr }, 'Rclone command warning');
100
+ reject(new Error(stderr || `Rclone exited with code ${code}`));
101
+ }
102
+ });
103
+ process.on('error', (err) => reject(err));
104
+ });
105
+ }
106
+ async fileExists(remoteFolder, fileName) {
107
+ if (!this.activeRemote)
108
+ return false;
109
+ try {
110
+ const remotePath = `${this.activeRemote}:${remoteFolder}/${fileName}`;
111
+ const output = await this.executeRcloneArgsWithOutput(['lsjson', remotePath]);
112
+ const json = JSON.parse(output);
113
+ return json && json.length > 0;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ }
120
+ export const rcloneService = new RcloneService();
@@ -0,0 +1,123 @@
1
+ import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
2
+ export const ASMR_STATUSES = [
3
+ 'Listening',
4
+ 'Completed',
5
+ 'On-Hold',
6
+ 'Dropped',
7
+ 'Planned',
8
+ ];
9
+ export function buildAsmrId(rjCode) {
10
+ return String(rjCode).trim().toUpperCase();
11
+ }
12
+ export const AsmrLibraryRepository = {
13
+ getById: (db, id) => dbGet(db, 'SELECT * FROM asmr_library WHERE id = ?', [id]),
14
+ exists: (db, id) => {
15
+ const row = dbGet(db, 'SELECT EXISTS(SELECT 1 FROM asmr_library WHERE id = ?) as inLibrary', [id]);
16
+ return !!(row && row.inLibrary);
17
+ },
18
+ getAll: (db, status, limit, offset) => {
19
+ let query = 'SELECT * FROM asmr_library';
20
+ const params = [];
21
+ if (status && status !== 'All') {
22
+ query += ' WHERE status = ?';
23
+ params.push(status);
24
+ }
25
+ query += ' ORDER BY updatedAt DESC';
26
+ if (limit !== undefined && offset !== undefined) {
27
+ query += ' LIMIT ? OFFSET ?';
28
+ params.push(limit, offset);
29
+ }
30
+ return dbAll(db, query, params);
31
+ },
32
+ getCount: (db, status) => {
33
+ let query = 'SELECT COUNT(*) as total FROM asmr_library';
34
+ const params = [];
35
+ if (status && status !== 'All') {
36
+ query += ' WHERE status = ?';
37
+ params.push(status);
38
+ }
39
+ const row = dbGet(db, query, params);
40
+ return row?.total || 0;
41
+ },
42
+ getIds: (db) => dbAll(db, 'SELECT id FROM asmr_library').map((r) => r.id),
43
+ upsert: (db, data) => dbRun(db, `INSERT INTO asmr_library (id, rjCode, title, thumbnail, status, isAdult, updatedAt)
44
+ VALUES (?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
45
+ ON CONFLICT(id) DO UPDATE SET
46
+ rjCode = COALESCE(NULLIF(EXCLUDED.rjCode, ''), asmr_library.rjCode),
47
+ title = COALESCE(NULLIF(EXCLUDED.title, ''), asmr_library.title),
48
+ thumbnail = COALESCE(NULLIF(EXCLUDED.thumbnail, ''), asmr_library.thumbnail),
49
+ status = COALESCE(NULLIF(EXCLUDED.status, ''), asmr_library.status),
50
+ isAdult = COALESCE(EXCLUDED.isAdult, asmr_library.isAdult),
51
+ updatedAt = strftime('%s', 'now')`, [data.id, data.rjCode, data.title, data.thumbnail, data.status, data.isAdult ? 1 : 0]),
52
+ updateStatus: (db, id, status) => dbRun(db, "UPDATE asmr_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id = ?", [status, id]),
53
+ updateStatusMany: (db, ids, status) => {
54
+ if (ids.length === 0)
55
+ return;
56
+ const placeholders = ids.map(() => '?').join(', ');
57
+ dbRun(db, `UPDATE asmr_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id IN (${placeholders})`, [status, ...ids]);
58
+ },
59
+ touchProgress: (db, id, progress) => dbRun(db, `UPDATE asmr_library SET lastTrackIndex = ?, lastTrackLabel = ?, lastPosition = ?, updatedAt = strftime('%s', 'now') WHERE id = ?`, [progress.trackIndex, progress.trackLabel, progress.position, id]),
60
+ delete: (db, id) => dbRun(db, 'DELETE FROM asmr_library WHERE id = ?', [id]),
61
+ deleteMany: (db, ids) => {
62
+ if (ids.length === 0)
63
+ return;
64
+ const placeholders = ids.map(() => '?').join(', ');
65
+ dbRun(db, `DELETE FROM asmr_library WHERE id IN (${placeholders})`, ids);
66
+ },
67
+ };
68
+ export const AsmrProgressRepository = {
69
+ getByWork: (db, workId) => dbAll(db, 'SELECT workId, trackIndex, trackLabel, currentTime, duration, updatedAt FROM asmr_progress WHERE workId = ? ORDER BY updatedAt DESC', [workId]),
70
+ getTrack: (db, workId, trackIndex) => dbGet(db, 'SELECT workId, trackIndex, trackLabel, currentTime, duration, updatedAt FROM asmr_progress WHERE workId = ? AND trackIndex = ?', [workId, trackIndex]),
71
+ getLatest: (db, workId) => dbGet(db, 'SELECT workId, trackIndex, trackLabel, currentTime, duration, updatedAt FROM asmr_progress WHERE workId = ? ORDER BY updatedAt DESC LIMIT 1', [workId]),
72
+ upsert: (db, data) => dbRun(db, `INSERT INTO asmr_progress (workId, trackIndex, trackLabel, currentTime, duration, title, thumbnail, rjCode, isAdult, updatedAt)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
74
+ ON CONFLICT(workId, trackIndex) DO UPDATE SET
75
+ trackLabel = COALESCE(NULLIF(EXCLUDED.trackLabel, ''), asmr_progress.trackLabel),
76
+ currentTime = EXCLUDED.currentTime,
77
+ duration = EXCLUDED.duration,
78
+ title = COALESCE(EXCLUDED.title, asmr_progress.title),
79
+ thumbnail = COALESCE(EXCLUDED.thumbnail, asmr_progress.thumbnail),
80
+ rjCode = COALESCE(EXCLUDED.rjCode, asmr_progress.rjCode),
81
+ isAdult = COALESCE(EXCLUDED.isAdult, asmr_progress.isAdult),
82
+ updatedAt = strftime('%s', 'now')`, [
83
+ data.workId,
84
+ data.trackIndex,
85
+ data.trackLabel,
86
+ data.currentTime,
87
+ data.duration,
88
+ data.title ?? null,
89
+ data.thumbnail ?? null,
90
+ data.rjCode ?? null,
91
+ data.isAdult ?? null,
92
+ ]),
93
+ deleteByWork: (db, workId) => dbRun(db, 'DELETE FROM asmr_progress WHERE workId = ?', [workId]),
94
+ deleteMany: (db, ids) => {
95
+ if (ids.length === 0)
96
+ return;
97
+ const placeholders = ids.map(() => '?').join(', ');
98
+ dbRun(db, `DELETE FROM asmr_progress WHERE workId IN (${placeholders})`, ids);
99
+ },
100
+ deleteTrack: (db, workId, trackIndex) => dbRun(db, 'DELETE FROM asmr_progress WHERE workId = ? AND trackIndex = ?', [
101
+ workId,
102
+ trackIndex,
103
+ ]),
104
+ getContinueListening: (db, limit) => {
105
+ const limitClause = typeof limit === 'number' ? `LIMIT ${limit}` : '';
106
+ return dbAll(db, `SELECT p.workId as id,
107
+ COALESCE(l.rjCode, p.rjCode, p.workId) as rjCode,
108
+ COALESCE(l.title, p.title) as title,
109
+ COALESCE(l.thumbnail, p.thumbnail) as thumbnail,
110
+ COALESCE(l.isAdult, p.isAdult) as isAdult,
111
+ l.status as watchlistStatus,
112
+ p.trackIndex, p.trackLabel, p.currentTime, p.duration, p.updatedAt as progressAt
113
+ FROM (
114
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY workId ORDER BY updatedAt DESC) as rn
115
+ FROM asmr_progress
116
+ ) p
117
+ LEFT JOIN asmr_library l ON p.workId = l.id
118
+ WHERE p.rn = 1
119
+ AND (l.status IS NULL OR l.status = 'Listening')
120
+ ORDER BY p.updatedAt DESC
121
+ ${limitClause}`);
122
+ },
123
+ };
@@ -0,0 +1,85 @@
1
+ import { dbAll, dbGet } from '../utils/db-utils.js';
2
+ export const InsightsRepository = {
3
+ getCoreStats: (db) => dbGet(db, `SELECT
4
+ (SELECT SUM(
5
+ CASE WHEN we.currentTime > 0 THEN we.currentTime
6
+ WHEN we.currentTime = 0 AND we.duration = 0 THEN
7
+ COALESCE(sm.episodeDuration * 60,
8
+ CASE UPPER(COALESCE(sm.type, ''))
9
+ WHEN 'MOVIE' THEN 6000
10
+ WHEN 'TV_SHORT' THEN 720
11
+ WHEN 'MUSIC' THEN 300
12
+ ELSE 1440 END)
13
+ ELSE 0 END)
14
+ FROM watched_episodes we
15
+ LEFT JOIN shows_meta sm ON sm.id = we.showId) as totalSeconds,
16
+ (SELECT COUNT(*) FROM watched_episodes) as totalEpisodes,
17
+ (SELECT COUNT(DISTINCT showId) FROM watched_episodes) as totalAnime,
18
+ (SELECT COUNT(*) FROM watchlist WHERE status = 'Completed') as completedCount,
19
+ (SELECT COUNT(*) FROM watchlist) as totalWatchlist`),
20
+ getActivityGrid: (db) => dbAll(db, `SELECT date(watchedAt) as day, COUNT(*) as count FROM watched_episodes WHERE NOT (currentTime = 0 AND duration = 0) GROUP BY day`),
21
+ getHourlyDist: (db) => dbAll(db, `SELECT strftime('%H', watchedAt) as hour, COUNT(*) as count FROM watched_episodes WHERE NOT (currentTime = 0 AND duration = 0) GROUP BY hour`),
22
+ getSeasonality: (db) => dbAll(db, `SELECT strftime('%m', we.watchedAt) as month, SUM(
23
+ CASE WHEN we.currentTime > 0 THEN we.currentTime
24
+ WHEN we.currentTime = 0 AND we.duration = 0 THEN
25
+ COALESCE(sm.episodeDuration * 60,
26
+ CASE UPPER(COALESCE(sm.type, ''))
27
+ WHEN 'MOVIE' THEN 6000
28
+ WHEN 'TV_SHORT' THEN 720
29
+ WHEN 'MUSIC' THEN 300
30
+ ELSE 1440 END)
31
+ ELSE 0 END) as seconds
32
+ FROM watched_episodes we
33
+ LEFT JOIN shows_meta sm ON sm.id = we.showId
34
+ GROUP BY month`),
35
+ getAllWatches: (db) => dbAll(db, `SELECT we.watchedAt, we.currentTime,
36
+ CASE WHEN we.currentTime > 0 THEN we.currentTime
37
+ WHEN we.currentTime = 0 AND we.duration = 0 THEN
38
+ COALESCE(sm.episodeDuration * 60,
39
+ CASE UPPER(COALESCE(sm.type, ''))
40
+ WHEN 'MOVIE' THEN 6000
41
+ WHEN 'TV_SHORT' THEN 720
42
+ WHEN 'MUSIC' THEN 300
43
+ ELSE 1440 END)
44
+ ELSE 0 END as effectiveSeconds
45
+ FROM watched_episodes we
46
+ LEFT JOIN shows_meta sm ON sm.id = we.showId
47
+ ORDER BY we.watchedAt ASC`),
48
+ getWatchedShowsMeta: (db) => dbAll(db, `SELECT DISTINCT sm.id, sm.genres, sm.popularityScore
49
+ FROM shows_meta sm
50
+ JOIN watched_episodes we ON sm.id = we.showId`),
51
+ getDroppedShows: (db) => dbAll(db, `SELECT w.id, w.name, MAX(we.watchedAt) as lastActivity
52
+ FROM watchlist w
53
+ JOIN watched_episodes we ON w.id = we.showId
54
+ WHERE w.status = 'Watching' AND NOT (we.currentTime = 0 AND we.duration = 0)
55
+ GROUP BY w.id
56
+ HAVING lastActivity < date('now', '-90 days')`),
57
+ getCompletionVelocities: (db) => dbAll(db, `SELECT
58
+ (julianday(MAX(we.watchedAt)) - julianday(MIN(we.watchedAt))) as daysToFinish
59
+ FROM watchlist w
60
+ JOIN watched_episodes we ON w.id = we.showId
61
+ WHERE w.status = 'Completed'
62
+ GROUP BY w.id
63
+ HAVING SUM(we.currentTime) > 0`),
64
+ getWatchedEpisodesWithMeta: (db) => dbAll(db, `SELECT
65
+ we.showId,
66
+ we.currentTime,
67
+ we.duration,
68
+ CASE WHEN we.currentTime > 0 THEN we.currentTime
69
+ WHEN we.currentTime = 0 AND we.duration = 0 THEN
70
+ COALESCE(sm.episodeDuration * 60,
71
+ CASE UPPER(COALESCE(sm.type, ''))
72
+ WHEN 'MOVIE' THEN 6000
73
+ WHEN 'TV_SHORT' THEN 720
74
+ WHEN 'MUSIC' THEN 300
75
+ ELSE 1440 END)
76
+ ELSE 0 END as effectiveSeconds,
77
+ sm.genres,
78
+ sm.popularityScore,
79
+ sm.name,
80
+ sm.nativeName,
81
+ sm.englishName,
82
+ sm.thumbnail
83
+ FROM watched_episodes we
84
+ JOIN shows_meta sm ON we.showId = sm.id`),
85
+ };
@@ -0,0 +1,108 @@
1
+ import { dbGet, dbRun } from '../utils/db-utils.js';
2
+ const LIBRARY_TABLES = [
3
+ 'watchlist',
4
+ 'watched_episodes',
5
+ 'queue',
6
+ 'shows_meta',
7
+ 'dismissed_notifications',
8
+ 'discovered_notifications',
9
+ 'legacy_id_mapping',
10
+ 'temp_show_ids',
11
+ ];
12
+ const MANGA_LIBRARY_TABLES = ['manga_library', 'manga_progress'];
13
+ const TV_LIBRARY_TABLES = ['tv_library', 'tv_progress'];
14
+ const ASMR_LIBRARY_TABLES = ['asmr_library', 'asmr_progress'];
15
+ export const LibraryRepository = {
16
+ countAll: (db) => {
17
+ const counts = {};
18
+ for (const table of LIBRARY_TABLES) {
19
+ try {
20
+ counts[table] =
21
+ dbGet(db, `SELECT COUNT(*) AS rows FROM "${table}"`)?.rows ?? 0;
22
+ }
23
+ catch {
24
+ counts[table] = 0;
25
+ }
26
+ }
27
+ return counts;
28
+ },
29
+ clearAll: (db) => {
30
+ for (const table of LIBRARY_TABLES) {
31
+ try {
32
+ dbRun(db, `DELETE FROM "${table}"`);
33
+ }
34
+ catch {
35
+ // ignore
36
+ }
37
+ }
38
+ },
39
+ countManga: (db) => {
40
+ const counts = {};
41
+ for (const table of MANGA_LIBRARY_TABLES) {
42
+ try {
43
+ counts[table] =
44
+ dbGet(db, `SELECT COUNT(*) AS rows FROM "${table}"`)?.rows ?? 0;
45
+ }
46
+ catch {
47
+ counts[table] = 0;
48
+ }
49
+ }
50
+ return counts;
51
+ },
52
+ clearManga: (db) => {
53
+ for (const table of MANGA_LIBRARY_TABLES) {
54
+ try {
55
+ dbRun(db, `DELETE FROM "${table}"`);
56
+ }
57
+ catch {
58
+ // ignore
59
+ }
60
+ }
61
+ },
62
+ countTv: (db) => {
63
+ const counts = {};
64
+ for (const table of TV_LIBRARY_TABLES) {
65
+ try {
66
+ counts[table] =
67
+ dbGet(db, `SELECT COUNT(*) AS rows FROM "${table}"`)?.rows ?? 0;
68
+ }
69
+ catch {
70
+ counts[table] = 0;
71
+ }
72
+ }
73
+ return counts;
74
+ },
75
+ clearTv: (db) => {
76
+ for (const table of TV_LIBRARY_TABLES) {
77
+ try {
78
+ dbRun(db, `DELETE FROM "${table}"`);
79
+ }
80
+ catch {
81
+ // ignore
82
+ }
83
+ }
84
+ },
85
+ countAsmr: (db) => {
86
+ const counts = {};
87
+ for (const table of ASMR_LIBRARY_TABLES) {
88
+ try {
89
+ counts[table] =
90
+ dbGet(db, `SELECT COUNT(*) AS rows FROM "${table}"`)?.rows ?? 0;
91
+ }
92
+ catch {
93
+ counts[table] = 0;
94
+ }
95
+ }
96
+ return counts;
97
+ },
98
+ clearAsmr: (db) => {
99
+ for (const table of ASMR_LIBRARY_TABLES) {
100
+ try {
101
+ dbRun(db, `DELETE FROM "${table}"`);
102
+ }
103
+ catch {
104
+ // ignore
105
+ }
106
+ }
107
+ },
108
+ };