@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,198 @@
1
+ import logger from '../logger.js';
2
+ import { InsightsRepository } from '../repositories/insights.repository.js';
3
+ import { computeDiscordSyncStats } from '../lib/discord-roles-sync.service.js';
4
+ export class InsightsController {
5
+ getWatchInsights = async (req, res) => {
6
+ const db = req.db;
7
+ const [core, activityGrid, hourlyDist, seasonality, allWatches, watchedShows, droppedWarning, velocities,] = (await Promise.all([
8
+ InsightsRepository.getCoreStats(db),
9
+ InsightsRepository.getActivityGrid(db),
10
+ InsightsRepository.getHourlyDist(db),
11
+ InsightsRepository.getSeasonality(db),
12
+ InsightsRepository.getAllWatches(db),
13
+ InsightsRepository.getWatchedShowsMeta(db),
14
+ InsightsRepository.getDroppedShows(db),
15
+ InsightsRepository.getCompletionVelocities(db),
16
+ ]));
17
+ const bingeFactor = activityGrid.length > 0 ? Math.max(...activityGrid.map((a) => a.count)) : 0;
18
+ const sessions = [];
19
+ if (allWatches.length > 0) {
20
+ let currentSessionSeconds = allWatches[0].effectiveSeconds ?? allWatches[0].currentTime;
21
+ for (let i = 1; i < allWatches.length; i++) {
22
+ const prev = new Date(allWatches[i - 1].watchedAt).getTime();
23
+ const curr = new Date(allWatches[i].watchedAt).getTime();
24
+ if (curr - prev < 3600000) {
25
+ currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
26
+ }
27
+ else {
28
+ sessions.push(currentSessionSeconds);
29
+ currentSessionSeconds = allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
30
+ }
31
+ }
32
+ sessions.push(currentSessionSeconds);
33
+ }
34
+ const avgSessionMinutes = sessions.length > 0
35
+ ? Math.round(sessions.reduce((a, b) => a + b, 0) / sessions.length / 60)
36
+ : 0;
37
+ const genreCounts = {};
38
+ let totalPopScore = 0;
39
+ let popCount = 0;
40
+ for (const show of watchedShows) {
41
+ let genres = [];
42
+ if (show.genres) {
43
+ try {
44
+ if (show.genres.startsWith('[')) {
45
+ genres = JSON.parse(show.genres);
46
+ }
47
+ else {
48
+ genres = show.genres.split(',').map((g) => g.trim());
49
+ }
50
+ }
51
+ catch (e) {
52
+ logger.warn({ err: e, showId: show.id }, 'Failed to parse genres for insights');
53
+ }
54
+ }
55
+ for (const g of genres) {
56
+ genreCounts[g] = (genreCounts[g] || 0) + 1;
57
+ }
58
+ if (show.popularityScore) {
59
+ let score = show.popularityScore;
60
+ if (score > 10)
61
+ score = score / 10;
62
+ totalPopScore += score;
63
+ popCount++;
64
+ }
65
+ }
66
+ const topGenre = Object.entries(genreCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
67
+ const personaMap = {
68
+ Action: 'Shonen Warrior',
69
+ Romance: 'Hopeless Romantic',
70
+ Comedy: 'Chaos Enjoyer',
71
+ 'Slice of Life': 'Vibe Seeker',
72
+ Horror: 'Fearless Watcher',
73
+ Fantasy: 'Isekai Traveller',
74
+ 'Sci-Fi': 'Future Scientist',
75
+ Drama: 'Feels Collector',
76
+ };
77
+ const persona = personaMap[topGenre || ''] || 'Anime Enthusiast';
78
+ const avgCompletionDays = velocities.length > 0
79
+ ? Math.round(velocities.reduce((a, b) => a + b.daysToFinish, 0) / velocities.length)
80
+ : 0;
81
+ res.json({
82
+ totalHours: Math.round((core?.totalSeconds || 0) / 3600),
83
+ totalEpisodes: core?.totalEpisodes || 0,
84
+ totalAnime: core?.totalAnime || 0,
85
+ completedAnime: core?.completedCount || 0,
86
+ completionRate: core?.totalWatchlist > 0
87
+ ? Math.round((core.completedCount / core.totalWatchlist) * 100)
88
+ : 0,
89
+ persona,
90
+ bingeFactor,
91
+ avgSessionMinutes,
92
+ avgCompletionDays,
93
+ popularityScore: popCount > 0 ? Math.round(totalPopScore / popCount) : 0,
94
+ genreSplit: Object.entries(genreCounts)
95
+ .map(([name, count]) => ({ name, count }))
96
+ .sort((a, b) => b.count - a.count)
97
+ .slice(0, 8) || [],
98
+ activityGrid: activityGrid || [],
99
+ hourlyDist: Array.from({ length: 24 }, (_, i) => {
100
+ const hour = i.toString().padStart(2, '0');
101
+ return { hour, count: hourlyDist?.find((d) => d.hour === hour)?.count || 0 };
102
+ }) || [],
103
+ seasonality: Array.from({ length: 12 }, (_, i) => {
104
+ const month = (i + 1).toString().padStart(2, '0');
105
+ return {
106
+ month,
107
+ seconds: seasonality?.find((s) => s.month === month)?.seconds || 0,
108
+ };
109
+ }) || [],
110
+ droppedShows: (droppedWarning || []).slice(0, 5),
111
+ });
112
+ };
113
+ formatTime(seconds) {
114
+ const days = Math.floor(seconds / 86400);
115
+ const hours = Math.floor((seconds % 86400) / 3600);
116
+ const minutes = Math.floor((seconds % 3600) / 60);
117
+ if (days > 0) {
118
+ return `${days} days ${hours > 0 ? `${hours} hours` : ''}`;
119
+ }
120
+ if (hours > 0) {
121
+ return `${hours} hours${minutes > 0 ? ` ${minutes} mins` : ''}`;
122
+ }
123
+ return `${minutes} mins`;
124
+ }
125
+ getGenreCards = async (req, res) => {
126
+ const db = req.db;
127
+ const rows = await InsightsRepository.getWatchedEpisodesWithMeta(db);
128
+ const genreData = {};
129
+ for (const row of rows) {
130
+ let genres = [];
131
+ if (row.genres) {
132
+ try {
133
+ if (row.genres.startsWith('[')) {
134
+ genres = JSON.parse(row.genres);
135
+ }
136
+ else {
137
+ genres = row.genres.split(',').map((g) => g.trim());
138
+ }
139
+ }
140
+ catch (e) {
141
+ logger.warn({ err: e, showId: row.showId }, 'Failed to parse genres for genre cards');
142
+ }
143
+ }
144
+ const timeWatched = (row.effectiveSeconds ?? row.currentTime) || 0;
145
+ let score = row.popularityScore || 0;
146
+ if (score > 10)
147
+ score = score / 10;
148
+ for (const genre of genres) {
149
+ if (!genreData[genre]) {
150
+ genreData[genre] = { count: 0, totalTime: 0, scores: [], showWatches: {}, showMeta: {} };
151
+ }
152
+ genreData[genre].count++;
153
+ genreData[genre].totalTime += timeWatched;
154
+ if (score > 0)
155
+ genreData[genre].scores.push(score);
156
+ genreData[genre].showWatches[row.showId] =
157
+ (genreData[genre].showWatches[row.showId] || 0) + 1;
158
+ if (row.name && row.thumbnail) {
159
+ genreData[genre].showMeta[row.showId] = {
160
+ name: row.name,
161
+ nativeName: row.nativeName,
162
+ englishName: row.englishName,
163
+ thumbnail: row.thumbnail,
164
+ };
165
+ }
166
+ }
167
+ }
168
+ const genreCards = Object.entries(genreData).map(([name, data]) => {
169
+ const topShows = Object.entries(data.showWatches)
170
+ .sort((a, b) => b[1] - a[1])
171
+ .slice(0, 4)
172
+ .map(([showId]) => ({
173
+ id: showId,
174
+ name: data.showMeta[showId]?.name || '',
175
+ nativeName: data.showMeta[showId]?.nativeName,
176
+ englishName: data.showMeta[showId]?.englishName,
177
+ thumbnail: data.showMeta[showId]?.thumbnail || '',
178
+ }));
179
+ return {
180
+ rank: 0,
181
+ name,
182
+ count: data.count,
183
+ meanScore: data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : 0,
184
+ timeWatched: this.formatTime(data.totalTime),
185
+ topShows,
186
+ };
187
+ });
188
+ genreCards.sort((a, b) => b.count - a.count);
189
+ genreCards.forEach((card, i) => {
190
+ card.rank = i + 1;
191
+ });
192
+ res.json(genreCards);
193
+ };
194
+ getDiscordSyncStats = async (req, res) => {
195
+ const db = req.db;
196
+ res.json(await computeDiscordSyncStats(db));
197
+ };
198
+ }
@@ -0,0 +1,296 @@
1
+ import logger from '../logger.js';
2
+ import { performMangaWriteTransaction } from '../sync.js';
3
+ import { dbAll } from '../utils/db-utils.js';
4
+ import { SettingsRepository } from '../repositories/settings.repository.js';
5
+ import { buildMangaId, MANGA_STATUSES, MangaLibraryRepository, MangaProgressRepository, } from '../repositories/manga.repository.js';
6
+ const MANGA_ADULT_RATINGS = ['erotica', 'pornographic'];
7
+ const isMangaContinueAdult = (row) => !!row.contentRating && MANGA_ADULT_RATINGS.includes(row.contentRating);
8
+ function mangaDb(req) {
9
+ const db = req.mangaDb;
10
+ if (!db || db.isClosedCheck())
11
+ throw new Error('Manga database is not ready');
12
+ return db;
13
+ }
14
+ function normalizeId(provider, mangaId, idRaw) {
15
+ if (idRaw && idRaw.includes(':'))
16
+ return idRaw;
17
+ return buildMangaId(provider, mangaId || idRaw || '');
18
+ }
19
+ export class MangaLibraryController {
20
+ getLibrary = async (req, res) => {
21
+ try {
22
+ const { status, page: pageStr, limit: limitStr } = req.query;
23
+ const page = Math.max(parseInt(pageStr) || 1, 1);
24
+ const limit = Math.min(Math.max(parseInt(limitStr) || 24, 1), 100);
25
+ const offset = (page - 1) * limit;
26
+ const db = mangaDb(req);
27
+ const rows = MangaLibraryRepository.getAll(db, status, limit, offset);
28
+ const total = MangaLibraryRepository.getCount(db, status);
29
+ res.json({ data: rows, total, page, limit });
30
+ }
31
+ catch (err) {
32
+ logger.error({ err }, 'Failed to get manga library');
33
+ res.status(500).json({ error: 'Failed to load manga library' });
34
+ }
35
+ };
36
+ checkLibrary = async (req, res) => {
37
+ try {
38
+ const item = MangaLibraryRepository.getById(mangaDb(req), req.params.id);
39
+ res.json({ inLibrary: !!item, status: item?.status ?? null });
40
+ }
41
+ catch {
42
+ res.json({ inLibrary: false, status: null });
43
+ }
44
+ };
45
+ getLibraryIds = async (req, res) => {
46
+ try {
47
+ res.json({ ids: MangaLibraryRepository.getIds(mangaDb(req)) });
48
+ }
49
+ catch {
50
+ res.json({ ids: [] });
51
+ }
52
+ };
53
+ addToLibrary = async (req, res) => {
54
+ const { provider, mangaId, id: idRaw, title, cover, status, author, altTitle, contentRating, } = req.body ?? {};
55
+ if (!provider || (!mangaId && !idRaw)) {
56
+ return res.status(400).json({ error: 'provider and mangaId are required' });
57
+ }
58
+ const id = normalizeId(String(provider), String(mangaId || ''), idRaw ? String(idRaw) : undefined);
59
+ const cleanStatus = typeof status === 'string' && MANGA_STATUSES.includes(status)
60
+ ? status
61
+ : 'Reading';
62
+ try {
63
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
64
+ MangaLibraryRepository.upsert(tx, {
65
+ id,
66
+ provider: String(provider).toLowerCase(),
67
+ mangaId: String(mangaId || idRaw || ''),
68
+ title: String(title || ''),
69
+ cover: String(cover || ''),
70
+ status: cleanStatus,
71
+ author: author ? String(author) : undefined,
72
+ altTitle: altTitle ? String(altTitle) : undefined,
73
+ contentRating: contentRating ? String(contentRating) : undefined,
74
+ });
75
+ });
76
+ res.json({ success: true, id });
77
+ }
78
+ catch (err) {
79
+ logger.error({ err }, 'Failed to add manga to library');
80
+ res.status(500).json({ error: 'Failed to bookmark manga' });
81
+ }
82
+ };
83
+ removeFromLibrary = async (req, res) => {
84
+ const { id } = req.body ?? {};
85
+ if (!id)
86
+ return res.status(400).json({ error: 'id is required' });
87
+ try {
88
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
89
+ MangaLibraryRepository.delete(tx, String(id));
90
+ MangaProgressRepository.deleteByManga(tx, String(id));
91
+ });
92
+ res.json({ success: true });
93
+ }
94
+ catch (err) {
95
+ logger.error({ err }, 'Failed to remove manga from library');
96
+ res.status(500).json({ error: 'Failed to remove bookmark' });
97
+ }
98
+ };
99
+ updateStatus = async (req, res) => {
100
+ const { id, status } = req.body ?? {};
101
+ if (!id || !MANGA_STATUSES.includes(status)) {
102
+ return res.status(400).json({ error: 'id and a valid status are required' });
103
+ }
104
+ try {
105
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
106
+ MangaLibraryRepository.updateStatus(tx, String(id), String(status));
107
+ });
108
+ res.json({ success: true });
109
+ }
110
+ catch (err) {
111
+ logger.error({ err }, 'Failed to update manga status');
112
+ res.status(500).json({ error: 'Failed to update status' });
113
+ }
114
+ };
115
+ getProgress = async (req, res) => {
116
+ try {
117
+ const rows = MangaProgressRepository.getByManga(mangaDb(req), req.params.mangaId);
118
+ res.json({ progress: rows });
119
+ }
120
+ catch {
121
+ res.json({ progress: [] });
122
+ }
123
+ };
124
+ getLatestProgress = async (req, res) => {
125
+ try {
126
+ const row = MangaProgressRepository.getLatest(mangaDb(req), req.params.mangaId);
127
+ res.json(row || { page: 0, pageCount: 0 });
128
+ }
129
+ catch {
130
+ res.json({ page: 0, pageCount: 0 });
131
+ }
132
+ };
133
+ saveProgress = async (req, res) => {
134
+ const { mangaId, chapterId, chapterNumber, page, pageCount, title, cover, provider, altTitle, contentRating, } = req.body ?? {};
135
+ if (!mangaId || !chapterId) {
136
+ return res.status(400).json({ error: 'mangaId and chapterId are required' });
137
+ }
138
+ const pageNum = Math.max(Number(page) || 0, 0);
139
+ const pageCountNum = Math.max(Number(pageCount) || 0, 0);
140
+ try {
141
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
142
+ MangaProgressRepository.upsert(tx, {
143
+ mangaId: String(mangaId),
144
+ chapterId: String(chapterId),
145
+ chapterNumber: String(chapterNumber || ''),
146
+ page: pageNum,
147
+ pageCount: pageCountNum,
148
+ title: title ?? null,
149
+ cover: cover ?? null,
150
+ provider: provider ?? null,
151
+ altTitle: altTitle ?? null,
152
+ contentRating: contentRating ?? null,
153
+ });
154
+ MangaLibraryRepository.touchProgress(tx, String(mangaId), {
155
+ chapterId: String(chapterId),
156
+ chapterNumber: String(chapterNumber || ''),
157
+ page: pageNum,
158
+ });
159
+ });
160
+ res.json({ success: true });
161
+ }
162
+ catch (err) {
163
+ logger.error({ err }, 'Failed to save manga progress');
164
+ res.status(500).json({ error: 'Failed to save progress' });
165
+ }
166
+ };
167
+ getContinueReading = async (req, res) => {
168
+ try {
169
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 24, 1), 100);
170
+ const ignoreAdultRow = await SettingsRepository.getByKey(req.db, 'mangaIgnoreAdultContent');
171
+ const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
172
+ const listOnlyRow = await SettingsRepository.getByKey(req.db, 'mangaCwWatchlistOnly');
173
+ const listOnly = listOnlyRow
174
+ ? listOnlyRow.value === 'true' || listOnlyRow.value === '1'
175
+ : false;
176
+ const rows = MangaProgressRepository.getContinueReading(mangaDb(req), limit).filter((row) => {
177
+ if (ignoreAdult && isMangaContinueAdult(row))
178
+ return false;
179
+ if (listOnly && row.watchlistStatus !== 'Reading')
180
+ return false;
181
+ return true;
182
+ });
183
+ res.json({ data: rows, total: rows.length });
184
+ }
185
+ catch {
186
+ res.json({ data: [], total: 0 });
187
+ }
188
+ };
189
+ async getAdultNonListMangaIds(db) {
190
+ const rows = await dbAll(db, `SELECT DISTINCT p.mangaId as mangaId
191
+ FROM manga_progress p
192
+ LEFT JOIN manga_library l ON l.id = p.mangaId
193
+ WHERE COALESCE(l.contentRating, p.contentRating) IN ('erotica', 'pornographic')
194
+ AND l.id IS NULL`);
195
+ return rows.map((r) => r.mangaId);
196
+ }
197
+ getAdultContinueReadingCount = async (req, res) => {
198
+ try {
199
+ const ids = await this.getAdultNonListMangaIds(mangaDb(req));
200
+ res.json({ count: ids.length });
201
+ }
202
+ catch {
203
+ res.json({ count: 0 });
204
+ }
205
+ };
206
+ purgeAdultContinueReading = async (req, res) => {
207
+ try {
208
+ const ids = await this.getAdultNonListMangaIds(mangaDb(req));
209
+ if (ids.length > 0) {
210
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
211
+ MangaProgressRepository.deleteMany(tx, ids);
212
+ });
213
+ }
214
+ res.json({ success: true, removed: ids.length });
215
+ }
216
+ catch (err) {
217
+ logger.error({ err }, 'Failed to purge adult manga progress');
218
+ res.status(500).json({ error: 'Failed to purge adult entries' });
219
+ }
220
+ };
221
+ removeProgress = async (req, res) => {
222
+ const { mangaId, chapterId } = req.body ?? {};
223
+ if (!mangaId)
224
+ return res.status(400).json({ error: 'mangaId is required' });
225
+ try {
226
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
227
+ if (chapterId) {
228
+ MangaProgressRepository.deleteChapter(tx, String(mangaId), String(chapterId));
229
+ }
230
+ else {
231
+ MangaProgressRepository.deleteByManga(tx, String(mangaId));
232
+ }
233
+ });
234
+ res.json({ success: true });
235
+ }
236
+ catch (err) {
237
+ logger.error({ err }, 'Failed to remove manga progress');
238
+ res.status(500).json({ error: 'Failed to reset progress' });
239
+ }
240
+ };
241
+ batchUpdateStatus = async (req, res) => {
242
+ const { ids: idsRaw, status } = req.body ?? {};
243
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
244
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
245
+ }
246
+ if (!status || !MANGA_STATUSES.includes(status)) {
247
+ return res.status(400).json({ error: 'a valid status is required' });
248
+ }
249
+ const ids = idsRaw.map((id) => String(id));
250
+ try {
251
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
252
+ MangaLibraryRepository.updateStatusMany(tx, ids, String(status));
253
+ });
254
+ res.json({ success: true, updated: ids.length });
255
+ }
256
+ catch (err) {
257
+ logger.error({ err }, 'Failed to batch update manga status');
258
+ res.status(500).json({ error: 'Failed to update statuses' });
259
+ }
260
+ };
261
+ batchRemove = async (req, res) => {
262
+ const { ids: idsRaw } = req.body ?? {};
263
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
264
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
265
+ }
266
+ const ids = idsRaw.map((id) => String(id));
267
+ try {
268
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
269
+ MangaLibraryRepository.deleteMany(tx, ids);
270
+ MangaProgressRepository.deleteMany(tx, ids);
271
+ });
272
+ res.json({ success: true, removed: ids.length });
273
+ }
274
+ catch (err) {
275
+ logger.error({ err }, 'Failed to batch remove manga');
276
+ res.status(500).json({ error: 'Failed to remove bookmarks' });
277
+ }
278
+ };
279
+ batchRemoveProgress = async (req, res) => {
280
+ const { ids: idsRaw } = req.body ?? {};
281
+ if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
282
+ return res.status(400).json({ error: 'ids must be a non-empty array' });
283
+ }
284
+ const ids = idsRaw.map((id) => String(id));
285
+ try {
286
+ await performMangaWriteTransaction(mangaDb(req), (tx) => {
287
+ MangaProgressRepository.deleteMany(tx, ids);
288
+ });
289
+ res.json({ success: true, removed: ids.length });
290
+ }
291
+ catch (err) {
292
+ logger.error({ err }, 'Failed to batch remove manga progress');
293
+ res.status(500).json({ error: 'Failed to reset progress' });
294
+ }
295
+ };
296
+ }