@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,1128 @@
1
+ import * as fs from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import path from 'path';
4
+ import logger from './logger.js';
5
+ import { googleDriveService } from './google.js';
6
+ import { rcloneService } from './rclone.js';
7
+ import { githubSyncService } from './github-sync.js';
8
+ import { CONFIG } from './config.js';
9
+ import { DatabaseWrapper } from './db.js';
10
+ import { dbGet } from './utils/db-utils.js';
11
+ import { TempShowIdsRepository } from './repositories/temp-show-ids.repository.js';
12
+ import { malCachePruneExpired } from './repositories/mal-cache.repository.js';
13
+ import { notifySyncStart, notifySyncEnd } from './lib/ipc.js';
14
+ import { ANIME_SYNC_TABLES, exportTables, importTables, MANGA_SYNC_TABLES, TV_SYNC_TABLES, ASMR_SYNC_TABLES, normalizePayload, readPayloadVersion, } from './sync-payload.js';
15
+ const log = logger.child({ module: 'Sync' });
16
+ export const SYNC_TABLES = ANIME_SYNC_TABLES;
17
+ export { MANGA_SYNC_TABLES, TV_SYNC_TABLES, ASMR_SYNC_TABLES };
18
+ // anime_id_map (offline MAL<->AniList metadata) is intentionally local-only:
19
+ // it is rebuilt weekly from the upstream JSON dump and never synced.
20
+ async function exportSyncPayload(db) {
21
+ return exportTables(db, SYNC_TABLES);
22
+ }
23
+ function importSyncPayload(db, payload) {
24
+ importTables(db, SYNC_TABLES, payload, {
25
+ libraryTables: ['watchlist', 'watched_episodes'],
26
+ backupName: 'pre-sync-backup.db',
27
+ });
28
+ }
29
+ async function exportMangaSyncPayload(db) {
30
+ return exportTables(db, MANGA_SYNC_TABLES);
31
+ }
32
+ async function exportTvSyncPayload(db) {
33
+ return exportTables(db, TV_SYNC_TABLES);
34
+ }
35
+ async function exportAsmrSyncPayload(db) {
36
+ return exportTables(db, ASMR_SYNC_TABLES);
37
+ }
38
+ function importMangaSyncPayload(db, payload) {
39
+ importTables(db, MANGA_SYNC_TABLES, payload, {
40
+ libraryTables: ['manga_library', 'manga_progress'],
41
+ backupName: 'pre-sync-manga-backup.db',
42
+ });
43
+ }
44
+ function importTvSyncPayload(db, payload) {
45
+ importTables(db, TV_SYNC_TABLES, payload, {
46
+ libraryTables: ['tv_library', 'tv_progress'],
47
+ backupName: 'pre-sync-tv-backup.db',
48
+ });
49
+ }
50
+ function importAsmrSyncPayload(db, payload) {
51
+ importTables(db, ASMR_SYNC_TABLES, payload, {
52
+ libraryTables: ['asmr_library', 'asmr_progress'],
53
+ backupName: 'pre-sync-asmr-backup.db',
54
+ });
55
+ }
56
+ async function getRcloneRemotePayloadVersion(remoteFolder, fileName = CONFIG.RCLONE_SYNC_FILENAME) {
57
+ const exists = await rcloneService.fileExists(remoteFolder, fileName);
58
+ if (!exists)
59
+ return 0;
60
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_sync.json`);
61
+ try {
62
+ await rcloneService.downloadFile(remoteFolder, fileName, tempPath);
63
+ const content = await fs.readFile(tempPath, 'utf-8');
64
+ const payload = JSON.parse(content);
65
+ return readPayloadVersion(payload);
66
+ }
67
+ catch {
68
+ return 0;
69
+ }
70
+ finally {
71
+ if (existsSync(tempPath))
72
+ await fs.unlink(tempPath);
73
+ }
74
+ }
75
+ async function rcloneSyncUp(db, remoteFolder, fileName = CONFIG.RCLONE_SYNC_FILENAME) {
76
+ const payload = await exportSyncPayload(db);
77
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_up.json`);
78
+ try {
79
+ await fs.writeFile(tempPath, JSON.stringify(payload, null, 2));
80
+ await rcloneService.uploadFile(tempPath, remoteFolder, fileName);
81
+ }
82
+ finally {
83
+ if (existsSync(tempPath))
84
+ await fs.unlink(tempPath).catch(() => { });
85
+ }
86
+ }
87
+ async function rcloneSyncDown(db, remoteFolder, fileName = CONFIG.RCLONE_SYNC_FILENAME) {
88
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_down.json`);
89
+ try {
90
+ await rcloneService.downloadFile(remoteFolder, fileName, tempPath);
91
+ const content = await fs.readFile(tempPath, 'utf-8');
92
+ const payload = normalizePayload(JSON.parse(content), SYNC_TABLES, 'rclone');
93
+ importSyncPayload(db, payload);
94
+ return readPayloadVersion(payload);
95
+ }
96
+ finally {
97
+ if (existsSync(tempPath))
98
+ await fs.unlink(tempPath).catch(() => { });
99
+ }
100
+ }
101
+ async function rcloneMangaSyncUp(db, remoteFolder) {
102
+ const payload = await exportMangaSyncPayload(db);
103
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_manga_up.json`);
104
+ try {
105
+ await fs.writeFile(tempPath, JSON.stringify(payload, null, 2));
106
+ await rcloneService.uploadFile(tempPath, remoteFolder, CONFIG.MANGA_RCLONE_SYNC_FILENAME);
107
+ }
108
+ finally {
109
+ if (existsSync(tempPath))
110
+ await fs.unlink(tempPath).catch(() => { });
111
+ }
112
+ }
113
+ async function rcloneTvSyncUp(db, remoteFolder) {
114
+ const payload = await exportTvSyncPayload(db);
115
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_tv_up.json`);
116
+ try {
117
+ await fs.writeFile(tempPath, JSON.stringify(payload, null, 2));
118
+ await rcloneService.uploadFile(tempPath, remoteFolder, CONFIG.TV_RCLONE_SYNC_FILENAME);
119
+ }
120
+ finally {
121
+ if (existsSync(tempPath))
122
+ await fs.unlink(tempPath).catch(() => { });
123
+ }
124
+ }
125
+ async function rcloneAsmrSyncUp(db, remoteFolder) {
126
+ const payload = await exportAsmrSyncPayload(db);
127
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_asmr_up.json`);
128
+ try {
129
+ await fs.writeFile(tempPath, JSON.stringify(payload, null, 2));
130
+ await rcloneService.uploadFile(tempPath, remoteFolder, CONFIG.ASMR_RCLONE_SYNC_FILENAME);
131
+ }
132
+ finally {
133
+ if (existsSync(tempPath))
134
+ await fs.unlink(tempPath).catch(() => { });
135
+ }
136
+ }
137
+ async function rcloneMangaSyncDown(db, remoteFolder) {
138
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_manga_down.json`);
139
+ try {
140
+ await rcloneService.downloadFile(remoteFolder, CONFIG.MANGA_RCLONE_SYNC_FILENAME, tempPath);
141
+ const content = await fs.readFile(tempPath, 'utf-8');
142
+ const payload = normalizePayload(JSON.parse(content), MANGA_SYNC_TABLES, 'rclone manga');
143
+ importMangaSyncPayload(db, payload);
144
+ return readPayloadVersion(payload);
145
+ }
146
+ finally {
147
+ if (existsSync(tempPath))
148
+ await fs.unlink(tempPath).catch(() => { });
149
+ }
150
+ }
151
+ async function rcloneTvSyncDown(db, remoteFolder) {
152
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_tv_down.json`);
153
+ try {
154
+ await rcloneService.downloadFile(remoteFolder, CONFIG.TV_RCLONE_SYNC_FILENAME, tempPath);
155
+ const content = await fs.readFile(tempPath, 'utf-8');
156
+ const payload = normalizePayload(JSON.parse(content), TV_SYNC_TABLES, 'rclone tv');
157
+ importTvSyncPayload(db, payload);
158
+ return readPayloadVersion(payload);
159
+ }
160
+ finally {
161
+ if (existsSync(tempPath))
162
+ await fs.unlink(tempPath).catch(() => { });
163
+ }
164
+ }
165
+ async function rcloneAsmrSyncDown(db, remoteFolder) {
166
+ const tempPath = path.join(CONFIG.ROOT, `temp_${Date.now()}_rclone_asmr_down.json`);
167
+ try {
168
+ await rcloneService.downloadFile(remoteFolder, CONFIG.ASMR_RCLONE_SYNC_FILENAME, tempPath);
169
+ const content = await fs.readFile(tempPath, 'utf-8');
170
+ const payload = normalizePayload(JSON.parse(content), ASMR_SYNC_TABLES, 'rclone asmr');
171
+ importAsmrSyncPayload(db, payload);
172
+ return readPayloadVersion(payload);
173
+ }
174
+ finally {
175
+ if (existsSync(tempPath))
176
+ await fs.unlink(tempPath).catch(() => { });
177
+ }
178
+ }
179
+ class Mutex {
180
+ _locked = false;
181
+ _waiting = [];
182
+ async lock() {
183
+ return new Promise((resolve) => {
184
+ if (!this._locked) {
185
+ this._locked = true;
186
+ resolve();
187
+ }
188
+ else {
189
+ this._waiting.push(resolve);
190
+ }
191
+ });
192
+ }
193
+ unlock() {
194
+ if (this._waiting.length > 0) {
195
+ const resolve = this._waiting.shift();
196
+ resolve();
197
+ }
198
+ else {
199
+ this._locked = false;
200
+ }
201
+ }
202
+ }
203
+ const syncMutex = new Mutex();
204
+ let isSyncing = false;
205
+ let activeProvider = 'none';
206
+ export async function waitForSync() {
207
+ await syncMutex.lock();
208
+ syncMutex.unlock();
209
+ }
210
+ export function getActiveProvider() {
211
+ return activeProvider;
212
+ }
213
+ export async function initSyncProvider(preferred) {
214
+ const provider = preferred || process.env.SYNC_PROVIDER;
215
+ if (provider === 'github' && githubSyncService.isAuthenticated()) {
216
+ activeProvider = 'github';
217
+ log.info('Sync Provider: GitHub (Selected)');
218
+ return;
219
+ }
220
+ if (provider === 'google' && googleDriveService.isAuthenticated()) {
221
+ activeProvider = 'google';
222
+ log.info('Sync Provider: Google Drive API (Selected)');
223
+ return;
224
+ }
225
+ if (provider === 'rclone') {
226
+ const rcloneAvailable = await rcloneService.init();
227
+ if (rcloneAvailable) {
228
+ activeProvider = 'rclone';
229
+ log.info(`Sync Provider: Rclone (${rcloneService.getRemoteName()}) (Selected)`);
230
+ return;
231
+ }
232
+ }
233
+ if (provider === 'none') {
234
+ activeProvider = 'none';
235
+ log.info('Sync Provider: None (Forced)');
236
+ return;
237
+ }
238
+ if (githubSyncService.isAuthenticated()) {
239
+ activeProvider = 'github';
240
+ log.info('Sync Provider: GitHub (Fallback)');
241
+ return;
242
+ }
243
+ if (googleDriveService.isAuthenticated()) {
244
+ activeProvider = 'google';
245
+ log.info('Sync Provider: Google Drive API (Fallback)');
246
+ return;
247
+ }
248
+ const rcloneAvailable = await rcloneService.init();
249
+ if (rcloneAvailable) {
250
+ activeProvider = 'rclone';
251
+ log.info(`Sync Provider: Rclone (${rcloneService.getRemoteName()}) (Fallback)`);
252
+ return;
253
+ }
254
+ activeProvider = 'none';
255
+ log.info('No sync provider available.');
256
+ }
257
+ export async function getLocalManifestVersion() {
258
+ if (existsSync(CONFIG.LOCAL_MANIFEST_PATH)) {
259
+ try {
260
+ const content = await fs.readFile(CONFIG.LOCAL_MANIFEST_PATH, 'utf-8');
261
+ return JSON.parse(content).version || 0;
262
+ }
263
+ catch {
264
+ return 0;
265
+ }
266
+ }
267
+ return 0;
268
+ }
269
+ export async function setLocalManifestVersion(version) {
270
+ await fs.writeFile(CONFIG.LOCAL_MANIFEST_PATH, JSON.stringify({ version }));
271
+ }
272
+ export async function getLocalMangaManifestVersion() {
273
+ if (existsSync(CONFIG.MANGA_LOCAL_MANIFEST_PATH)) {
274
+ try {
275
+ const content = await fs.readFile(CONFIG.MANGA_LOCAL_MANIFEST_PATH, 'utf-8');
276
+ return JSON.parse(content).version || 0;
277
+ }
278
+ catch {
279
+ return 0;
280
+ }
281
+ }
282
+ return 0;
283
+ }
284
+ export async function setLocalMangaManifestVersion(version) {
285
+ await fs.writeFile(CONFIG.MANGA_LOCAL_MANIFEST_PATH, JSON.stringify({ version }));
286
+ }
287
+ export async function getLocalTvManifestVersion() {
288
+ if (existsSync(CONFIG.TV_LOCAL_MANIFEST_PATH)) {
289
+ try {
290
+ const content = await fs.readFile(CONFIG.TV_LOCAL_MANIFEST_PATH, 'utf-8');
291
+ return JSON.parse(content).version || 0;
292
+ }
293
+ catch {
294
+ return 0;
295
+ }
296
+ }
297
+ return 0;
298
+ }
299
+ export async function setLocalTvManifestVersion(version) {
300
+ await fs.writeFile(CONFIG.TV_LOCAL_MANIFEST_PATH, JSON.stringify({ version }));
301
+ }
302
+ export async function getLocalAsmrManifestVersion() {
303
+ if (existsSync(CONFIG.ASMR_LOCAL_MANIFEST_PATH)) {
304
+ try {
305
+ const content = await fs.readFile(CONFIG.ASMR_LOCAL_MANIFEST_PATH, 'utf-8');
306
+ return JSON.parse(content).version || 0;
307
+ }
308
+ catch {
309
+ return 0;
310
+ }
311
+ }
312
+ return 0;
313
+ }
314
+ export async function setLocalAsmrManifestVersion(version) {
315
+ await fs.writeFile(CONFIG.ASMR_LOCAL_MANIFEST_PATH, JSON.stringify({ version }));
316
+ }
317
+ async function getRemoteManifestVersion(remoteFolder) {
318
+ try {
319
+ if (activeProvider === 'github') {
320
+ if (!githubSyncService.isAuthenticated())
321
+ return { version: 0 };
322
+ return { version: await githubSyncService.getRemoteVersion() };
323
+ }
324
+ else if (activeProvider === 'google') {
325
+ if (!googleDriveService.isAuthenticated())
326
+ return { version: 0 };
327
+ return { version: await googleDriveService.getRemoteVersion() };
328
+ }
329
+ else if (activeProvider === 'rclone') {
330
+ return { version: await getRcloneRemotePayloadVersion(remoteFolder) };
331
+ }
332
+ }
333
+ catch (err) {
334
+ log.warn({ err }, 'Could not read remote manifest.');
335
+ }
336
+ return { version: 0 };
337
+ }
338
+ async function getMangaRemoteManifestVersion(remoteFolder) {
339
+ try {
340
+ if (activeProvider === 'github') {
341
+ if (!githubSyncService.isAuthenticated())
342
+ return { version: 0 };
343
+ return { version: await githubSyncService.getMangaRemoteVersion() };
344
+ }
345
+ else if (activeProvider === 'google') {
346
+ if (!googleDriveService.isAuthenticated())
347
+ return { version: 0 };
348
+ return { version: await googleDriveService.getMangaRemoteVersion() };
349
+ }
350
+ else if (activeProvider === 'rclone') {
351
+ return {
352
+ version: await getRcloneRemotePayloadVersion(remoteFolder, CONFIG.MANGA_RCLONE_SYNC_FILENAME),
353
+ };
354
+ }
355
+ }
356
+ catch (err) {
357
+ log.warn({ err }, 'Could not read remote manga manifest.');
358
+ }
359
+ return { version: 0 };
360
+ }
361
+ async function getTvRemoteManifestVersion(remoteFolder) {
362
+ try {
363
+ if (activeProvider === 'github') {
364
+ if (!githubSyncService.isAuthenticated())
365
+ return { version: 0 };
366
+ return { version: await githubSyncService.getTvRemoteVersion() };
367
+ }
368
+ else if (activeProvider === 'google') {
369
+ if (!googleDriveService.isAuthenticated())
370
+ return { version: 0 };
371
+ return { version: await googleDriveService.getTvRemoteVersion() };
372
+ }
373
+ else if (activeProvider === 'rclone') {
374
+ return {
375
+ version: await getRcloneRemotePayloadVersion(remoteFolder, CONFIG.TV_RCLONE_SYNC_FILENAME),
376
+ };
377
+ }
378
+ }
379
+ catch (err) {
380
+ log.warn({ err }, 'Could not read remote TV manifest.');
381
+ }
382
+ return { version: 0 };
383
+ }
384
+ async function getAsmrRemoteManifestVersion(remoteFolder) {
385
+ try {
386
+ if (activeProvider === 'github') {
387
+ if (!githubSyncService.isAuthenticated())
388
+ return { version: 0 };
389
+ return { version: await githubSyncService.getAsmrRemoteVersion() };
390
+ }
391
+ else if (activeProvider === 'google') {
392
+ if (!googleDriveService.isAuthenticated())
393
+ return { version: 0 };
394
+ return { version: await googleDriveService.getAsmrRemoteVersion() };
395
+ }
396
+ else if (activeProvider === 'rclone') {
397
+ return {
398
+ version: await getRcloneRemotePayloadVersion(remoteFolder, CONFIG.ASMR_RCLONE_SYNC_FILENAME),
399
+ };
400
+ }
401
+ }
402
+ catch (err) {
403
+ log.warn({ err }, 'Could not read remote ASMR manifest.');
404
+ }
405
+ return { version: 0 };
406
+ }
407
+ export async function syncDownOnBoot(db, dbPath, remoteFolderName, closeMainDb) {
408
+ let localVersion = await getLocalManifestVersion();
409
+ if (localVersion === 0 && db) {
410
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
411
+ localVersion = row?.value ?? 0;
412
+ if (localVersion > 0) {
413
+ await setLocalManifestVersion(localVersion);
414
+ }
415
+ }
416
+ if (activeProvider === 'none')
417
+ return false;
418
+ await syncMutex.lock();
419
+ if (isSyncing) {
420
+ syncMutex.unlock();
421
+ return false;
422
+ }
423
+ isSyncing = true;
424
+ try {
425
+ notifySyncStart(`Initial sync check (${activeProvider})`);
426
+ const { version: remoteVersion } = await getRemoteManifestVersion(remoteFolderName);
427
+ notifySyncEnd();
428
+ log.info(`Sync Check: Local v${localVersion} vs Remote v${remoteVersion}`);
429
+ if (remoteVersion > localVersion) {
430
+ if (activeProvider === 'github') {
431
+ if (!githubSyncService.isAuthenticated())
432
+ return false;
433
+ notifySyncStart(`Importing GitHub sync data (Remote v${remoteVersion})`);
434
+ const importedVersion = await githubSyncService.syncDown(db);
435
+ await setLocalManifestVersion(importedVersion || remoteVersion);
436
+ notifySyncEnd();
437
+ log.info('GitHub sync down complete.');
438
+ return false;
439
+ }
440
+ if (activeProvider === 'google') {
441
+ if (!googleDriveService.isAuthenticated())
442
+ return false;
443
+ notifySyncStart(`Importing Google sync data (Remote v${remoteVersion})`);
444
+ const importedVersion = await googleDriveService.syncDown(db);
445
+ await setLocalManifestVersion(importedVersion || remoteVersion);
446
+ notifySyncEnd();
447
+ log.info('Google sync down complete.');
448
+ return false;
449
+ }
450
+ if (activeProvider === 'rclone') {
451
+ notifySyncStart(`Importing Rclone sync data (Remote v${remoteVersion})`);
452
+ const importedVersion = await rcloneSyncDown(db, remoteFolderName);
453
+ await setLocalManifestVersion(importedVersion || remoteVersion);
454
+ notifySyncEnd();
455
+ log.info('Rclone sync down complete.');
456
+ return false;
457
+ }
458
+ notifySyncStart(`Downloading remote database (Remote v${remoteVersion})`);
459
+ await closeMainDb();
460
+ const backupPath = `${dbPath}.bak`;
461
+ try {
462
+ if (existsSync(dbPath)) {
463
+ await fs.copyFile(dbPath, backupPath);
464
+ }
465
+ try {
466
+ await fs.unlink(`${dbPath}-wal`);
467
+ }
468
+ catch (e) {
469
+ void e;
470
+ }
471
+ try {
472
+ await fs.unlink(`${dbPath}-shm`);
473
+ }
474
+ catch (e) {
475
+ void e;
476
+ }
477
+ log.warn('Legacy raw-db sync path reached with JSON providers. No action taken.');
478
+ if (existsSync(backupPath)) {
479
+ await fs.unlink(backupPath);
480
+ }
481
+ notifySyncEnd();
482
+ log.info('Sync down complete.');
483
+ return true;
484
+ }
485
+ catch (err) {
486
+ notifySyncEnd();
487
+ log.error({ err }, 'Sync down failed. Restoring backup.');
488
+ if (existsSync(backupPath)) {
489
+ try {
490
+ await fs.copyFile(backupPath, dbPath);
491
+ log.info('Backup restored successfully after failed sync down.');
492
+ }
493
+ catch (restoreErr) {
494
+ log.error({ err: restoreErr }, 'Critical: restore from backup also failed.');
495
+ throw new Error('Sync down and restore both failed. Database may be corrupt.', {
496
+ cause: restoreErr,
497
+ });
498
+ }
499
+ }
500
+ return true;
501
+ }
502
+ }
503
+ else {
504
+ log.info('Local DB is up to date.');
505
+ return false;
506
+ }
507
+ }
508
+ catch (err) {
509
+ notifySyncEnd();
510
+ log.error({ err }, 'Sync boot error.');
511
+ return false;
512
+ }
513
+ finally {
514
+ isSyncing = false;
515
+ syncMutex.unlock();
516
+ }
517
+ }
518
+ export async function syncUp(db, dbPath, remoteFolderName) {
519
+ if (activeProvider === 'none')
520
+ return;
521
+ await syncMutex.lock();
522
+ if (isSyncing) {
523
+ syncMutex.unlock();
524
+ return;
525
+ }
526
+ isSyncing = true;
527
+ try {
528
+ const localVersion = await getLocalManifestVersion();
529
+ notifySyncStart(`Syncing up (Local v${localVersion})`);
530
+ const { version: remoteVersion } = await getRemoteManifestVersion(remoteFolderName);
531
+ if (localVersion > remoteVersion) {
532
+ if (activeProvider === 'github') {
533
+ if (!githubSyncService.isAuthenticated())
534
+ return;
535
+ await githubSyncService.syncUp(db);
536
+ }
537
+ else if (activeProvider === 'google') {
538
+ if (!googleDriveService.isAuthenticated())
539
+ return;
540
+ await googleDriveService.syncUp(db);
541
+ }
542
+ else if (activeProvider === 'rclone') {
543
+ await rcloneSyncUp(db, remoteFolderName);
544
+ }
545
+ notifySyncEnd();
546
+ log.info('Sync up complete.');
547
+ }
548
+ else {
549
+ notifySyncEnd();
550
+ log.info('No changes to sync up or remote is newer.');
551
+ }
552
+ }
553
+ catch (err) {
554
+ notifySyncEnd();
555
+ log.error({ err }, 'Sync up failed.');
556
+ }
557
+ finally {
558
+ isSyncing = false;
559
+ syncMutex.unlock();
560
+ }
561
+ }
562
+ export async function performWriteTransaction(db, runnable) {
563
+ db.serialize(() => {
564
+ runnable(db);
565
+ db.run("UPDATE sync_metadata SET value = value + 1 WHERE key = 'db_version'");
566
+ });
567
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
568
+ const newVersion = row?.value ?? 1;
569
+ await setLocalManifestVersion(newVersion);
570
+ }
571
+ export async function performMangaWriteTransaction(db, runnable) {
572
+ db.serialize(() => {
573
+ runnable(db);
574
+ db.run("UPDATE sync_metadata SET value = value + 1 WHERE key = 'db_version'");
575
+ });
576
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
577
+ const newVersion = row?.value ?? 1;
578
+ await setLocalMangaManifestVersion(newVersion);
579
+ }
580
+ export async function performTvWriteTransaction(db, runnable) {
581
+ db.serialize(() => {
582
+ runnable(db);
583
+ db.run("UPDATE sync_metadata SET value = value + 1 WHERE key = 'db_version'");
584
+ });
585
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
586
+ const newVersion = row?.value ?? 1;
587
+ await setLocalTvManifestVersion(newVersion);
588
+ }
589
+ export async function performAsmrWriteTransaction(db, runnable) {
590
+ db.serialize(() => {
591
+ runnable(db);
592
+ db.run("UPDATE sync_metadata SET value = value + 1 WHERE key = 'db_version'");
593
+ });
594
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
595
+ const newVersion = row?.value ?? 1;
596
+ await setLocalAsmrManifestVersion(newVersion);
597
+ }
598
+ export async function mangaSyncDownOnBoot(db, remoteFolderName) {
599
+ let localVersion = await getLocalMangaManifestVersion();
600
+ if (localVersion === 0 && db) {
601
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
602
+ localVersion = row?.value ?? 0;
603
+ if (localVersion > 0) {
604
+ await setLocalMangaManifestVersion(localVersion);
605
+ }
606
+ }
607
+ if (activeProvider === 'none')
608
+ return;
609
+ await syncMutex.lock();
610
+ if (isSyncing) {
611
+ syncMutex.unlock();
612
+ return;
613
+ }
614
+ isSyncing = true;
615
+ try {
616
+ notifySyncStart(`Initial manga sync check (${activeProvider})`);
617
+ const { version: remoteVersion } = await getMangaRemoteManifestVersion(remoteFolderName);
618
+ notifySyncEnd();
619
+ log.info(`Manga Sync Check: Local v${localVersion} vs Remote v${remoteVersion}`);
620
+ if (remoteVersion > localVersion) {
621
+ if (activeProvider === 'github') {
622
+ if (!githubSyncService.isAuthenticated())
623
+ return;
624
+ notifySyncStart(`Importing GitHub manga sync data (Remote v${remoteVersion})`);
625
+ const importedVersion = await githubSyncService.syncMangaDown(db);
626
+ await setLocalMangaManifestVersion(importedVersion || remoteVersion);
627
+ notifySyncEnd();
628
+ log.info('GitHub manga sync down complete.');
629
+ return;
630
+ }
631
+ if (activeProvider === 'google') {
632
+ if (!googleDriveService.isAuthenticated())
633
+ return;
634
+ notifySyncStart(`Importing Google manga sync data (Remote v${remoteVersion})`);
635
+ const importedVersion = await googleDriveService.syncMangaDown(db);
636
+ await setLocalMangaManifestVersion(importedVersion || remoteVersion);
637
+ notifySyncEnd();
638
+ log.info('Google manga sync down complete.');
639
+ return;
640
+ }
641
+ if (activeProvider === 'rclone') {
642
+ notifySyncStart(`Importing Rclone manga sync data (Remote v${remoteVersion})`);
643
+ const importedVersion = await rcloneMangaSyncDown(db, remoteFolderName);
644
+ await setLocalMangaManifestVersion(importedVersion || remoteVersion);
645
+ notifySyncEnd();
646
+ log.info('Rclone manga sync down complete.');
647
+ return;
648
+ }
649
+ }
650
+ else {
651
+ log.info('Local manga DB is up to date.');
652
+ }
653
+ }
654
+ catch (err) {
655
+ notifySyncEnd();
656
+ log.error({ err }, 'Manga sync boot error.');
657
+ }
658
+ finally {
659
+ isSyncing = false;
660
+ syncMutex.unlock();
661
+ }
662
+ }
663
+ export async function mangaSyncUp(db, remoteFolderName) {
664
+ if (activeProvider === 'none')
665
+ return;
666
+ await syncMutex.lock();
667
+ if (isSyncing) {
668
+ syncMutex.unlock();
669
+ return;
670
+ }
671
+ isSyncing = true;
672
+ try {
673
+ const localVersion = await getLocalMangaManifestVersion();
674
+ const { version: remoteVersion } = await getMangaRemoteManifestVersion(remoteFolderName);
675
+ if (localVersion > remoteVersion) {
676
+ notifySyncStart(`Syncing manga up (Local v${localVersion})`);
677
+ if (activeProvider === 'github') {
678
+ if (!githubSyncService.isAuthenticated())
679
+ return;
680
+ await githubSyncService.syncMangaUp(db);
681
+ }
682
+ else if (activeProvider === 'google') {
683
+ if (!googleDriveService.isAuthenticated())
684
+ return;
685
+ await googleDriveService.syncMangaUp(db);
686
+ }
687
+ else if (activeProvider === 'rclone') {
688
+ await rcloneMangaSyncUp(db, remoteFolderName);
689
+ }
690
+ notifySyncEnd();
691
+ log.info('Manga sync up complete.');
692
+ }
693
+ else {
694
+ log.info('No manga changes to sync up or remote is newer.');
695
+ }
696
+ }
697
+ catch (err) {
698
+ notifySyncEnd();
699
+ log.error({ err }, 'Manga sync up failed.');
700
+ }
701
+ finally {
702
+ isSyncing = false;
703
+ syncMutex.unlock();
704
+ }
705
+ }
706
+ export async function initializeMangaDatabase(dbPath) {
707
+ try {
708
+ const db = await DatabaseWrapper.create(dbPath);
709
+ db.configure('busyTimeout', 5000);
710
+ db.run('PRAGMA journal_mode = WAL;');
711
+ db.run('PRAGMA synchronous = NORMAL;');
712
+ db.run('PRAGMA cache_size = -20000;');
713
+ db.run('PRAGMA temp_store = MEMORY;');
714
+ db.run('PRAGMA mmap_size = 268435456;');
715
+ db.run('PRAGMA foreign_keys = ON;');
716
+ db.run(`CREATE TABLE IF NOT EXISTS manga_library (id TEXT PRIMARY KEY, provider TEXT NOT NULL, mangaId TEXT NOT NULL, title TEXT, cover TEXT, status TEXT DEFAULT 'Reading', author TEXT, contentRating TEXT, lastChapterId TEXT, lastChapterNumber TEXT, lastPage INTEGER, updatedAt INTEGER, altTitle TEXT)`);
717
+ db.run(`CREATE TABLE IF NOT EXISTS manga_progress (mangaId TEXT NOT NULL, chapterId TEXT NOT NULL, chapterNumber TEXT, page INTEGER DEFAULT 0, pageCount INTEGER DEFAULT 0, updatedAt INTEGER, PRIMARY KEY (mangaId, chapterId))`);
718
+ db.run(`CREATE TABLE IF NOT EXISTS sync_metadata (key TEXT PRIMARY KEY, value INTEGER)`);
719
+ db.run(`INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('db_version', 1)`);
720
+ db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_manga_library_provider_manga ON manga_library(provider, mangaId)`);
721
+ db.run(`CREATE INDEX IF NOT EXISTS idx_manga_library_status ON manga_library(status)`);
722
+ db.run(`CREATE INDEX IF NOT EXISTS idx_manga_progress_manga ON manga_progress(mangaId, updatedAt)`);
723
+ const mangaColumns = db.all(`PRAGMA table_info(manga_library)`);
724
+ if (!mangaColumns.some((c) => c.name === 'altTitle')) {
725
+ db.run(`ALTER TABLE manga_library ADD COLUMN altTitle TEXT`);
726
+ }
727
+ const mangaProgressColumns = db.all(`PRAGMA table_info(manga_progress)`);
728
+ if (!mangaProgressColumns.some((c) => c.name === 'title')) {
729
+ db.run(`ALTER TABLE manga_progress ADD COLUMN title TEXT`);
730
+ }
731
+ if (!mangaProgressColumns.some((c) => c.name === 'cover')) {
732
+ db.run(`ALTER TABLE manga_progress ADD COLUMN cover TEXT`);
733
+ }
734
+ if (!mangaProgressColumns.some((c) => c.name === 'provider')) {
735
+ db.run(`ALTER TABLE manga_progress ADD COLUMN provider TEXT`);
736
+ }
737
+ if (!mangaProgressColumns.some((c) => c.name === 'altTitle')) {
738
+ db.run(`ALTER TABLE manga_progress ADD COLUMN altTitle TEXT`);
739
+ }
740
+ if (!mangaProgressColumns.some((c) => c.name === 'contentRating')) {
741
+ db.run(`ALTER TABLE manga_progress ADD COLUMN contentRating TEXT`);
742
+ }
743
+ return db;
744
+ }
745
+ catch (err) {
746
+ log.error({ err }, 'Manga database opening error');
747
+ throw err;
748
+ }
749
+ }
750
+ export async function tvSyncDownOnBoot(db, remoteFolderName) {
751
+ let localVersion = await getLocalTvManifestVersion();
752
+ if (localVersion === 0 && db) {
753
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
754
+ localVersion = row?.value ?? 0;
755
+ if (localVersion > 0) {
756
+ await setLocalTvManifestVersion(localVersion);
757
+ }
758
+ }
759
+ if (activeProvider === 'none')
760
+ return;
761
+ await syncMutex.lock();
762
+ if (isSyncing) {
763
+ syncMutex.unlock();
764
+ return;
765
+ }
766
+ isSyncing = true;
767
+ try {
768
+ notifySyncStart(`Initial TV sync check (${activeProvider})`);
769
+ const { version: remoteVersion } = await getTvRemoteManifestVersion(remoteFolderName);
770
+ notifySyncEnd();
771
+ log.info(`TV Sync Check: Local v${localVersion} vs Remote v${remoteVersion}`);
772
+ if (remoteVersion > localVersion) {
773
+ if (activeProvider === 'github') {
774
+ if (!githubSyncService.isAuthenticated())
775
+ return;
776
+ notifySyncStart(`Importing GitHub TV sync data (Remote v${remoteVersion})`);
777
+ const importedVersion = await githubSyncService.syncTvDown(db);
778
+ await setLocalTvManifestVersion(importedVersion || remoteVersion);
779
+ notifySyncEnd();
780
+ log.info('GitHub TV sync down complete.');
781
+ return;
782
+ }
783
+ if (activeProvider === 'google') {
784
+ if (!googleDriveService.isAuthenticated())
785
+ return;
786
+ notifySyncStart(`Importing Google TV sync data (Remote v${remoteVersion})`);
787
+ const importedVersion = await googleDriveService.syncTvDown(db);
788
+ await setLocalTvManifestVersion(importedVersion || remoteVersion);
789
+ notifySyncEnd();
790
+ log.info('Google TV sync down complete.');
791
+ return;
792
+ }
793
+ if (activeProvider === 'rclone') {
794
+ notifySyncStart(`Importing Rclone TV sync data (Remote v${remoteVersion})`);
795
+ const importedVersion = await rcloneTvSyncDown(db, remoteFolderName);
796
+ await setLocalTvManifestVersion(importedVersion || remoteVersion);
797
+ notifySyncEnd();
798
+ log.info('Rclone TV sync down complete.');
799
+ return;
800
+ }
801
+ }
802
+ else {
803
+ log.info('Local TV DB is up to date.');
804
+ }
805
+ }
806
+ catch (err) {
807
+ notifySyncEnd();
808
+ log.error({ err }, 'TV sync boot error.');
809
+ }
810
+ finally {
811
+ isSyncing = false;
812
+ syncMutex.unlock();
813
+ }
814
+ }
815
+ export async function tvSyncUp(db, remoteFolderName) {
816
+ if (activeProvider === 'none')
817
+ return;
818
+ await syncMutex.lock();
819
+ if (isSyncing) {
820
+ syncMutex.unlock();
821
+ return;
822
+ }
823
+ isSyncing = true;
824
+ try {
825
+ const localVersion = await getLocalTvManifestVersion();
826
+ const { version: remoteVersion } = await getTvRemoteManifestVersion(remoteFolderName);
827
+ if (localVersion > remoteVersion) {
828
+ notifySyncStart(`Syncing TV up (Local v${localVersion})`);
829
+ if (activeProvider === 'github') {
830
+ if (!githubSyncService.isAuthenticated())
831
+ return;
832
+ await githubSyncService.syncTvUp(db);
833
+ }
834
+ else if (activeProvider === 'google') {
835
+ if (!googleDriveService.isAuthenticated())
836
+ return;
837
+ await googleDriveService.syncTvUp(db);
838
+ }
839
+ else if (activeProvider === 'rclone') {
840
+ await rcloneTvSyncUp(db, remoteFolderName);
841
+ }
842
+ notifySyncEnd();
843
+ log.info('TV sync up complete.');
844
+ }
845
+ else {
846
+ log.info('No TV changes to sync up or remote is newer.');
847
+ }
848
+ }
849
+ catch (err) {
850
+ notifySyncEnd();
851
+ log.error({ err }, 'TV sync up failed.');
852
+ }
853
+ finally {
854
+ isSyncing = false;
855
+ syncMutex.unlock();
856
+ }
857
+ }
858
+ export async function asmrSyncDownOnBoot(db, remoteFolderName) {
859
+ let localVersion = await getLocalAsmrManifestVersion();
860
+ if (localVersion === 0 && db) {
861
+ const row = dbGet(db, "SELECT value FROM sync_metadata WHERE key = 'db_version'");
862
+ localVersion = row?.value ?? 0;
863
+ if (localVersion > 0) {
864
+ await setLocalAsmrManifestVersion(localVersion);
865
+ }
866
+ }
867
+ if (activeProvider === 'none')
868
+ return;
869
+ await syncMutex.lock();
870
+ if (isSyncing) {
871
+ syncMutex.unlock();
872
+ return;
873
+ }
874
+ isSyncing = true;
875
+ try {
876
+ notifySyncStart(`Initial ASMR sync check (${activeProvider})`);
877
+ const { version: remoteVersion } = await getAsmrRemoteManifestVersion(remoteFolderName);
878
+ notifySyncEnd();
879
+ log.info(`ASMR Sync Check: Local v${localVersion} vs Remote v${remoteVersion}`);
880
+ if (remoteVersion > localVersion) {
881
+ if (activeProvider === 'github') {
882
+ if (!githubSyncService.isAuthenticated())
883
+ return;
884
+ notifySyncStart(`Importing GitHub ASMR sync data (Remote v${remoteVersion})`);
885
+ const importedVersion = await githubSyncService.syncAsmrDown(db);
886
+ await setLocalAsmrManifestVersion(importedVersion || remoteVersion);
887
+ notifySyncEnd();
888
+ log.info('GitHub ASMR sync down complete.');
889
+ return;
890
+ }
891
+ if (activeProvider === 'google') {
892
+ if (!googleDriveService.isAuthenticated())
893
+ return;
894
+ notifySyncStart(`Importing Google ASMR sync data (Remote v${remoteVersion})`);
895
+ const importedVersion = await googleDriveService.syncAsmrDown(db);
896
+ await setLocalAsmrManifestVersion(importedVersion || remoteVersion);
897
+ notifySyncEnd();
898
+ log.info('Google ASMR sync down complete.');
899
+ return;
900
+ }
901
+ if (activeProvider === 'rclone') {
902
+ notifySyncStart(`Importing Rclone ASMR sync data (Remote v${remoteVersion})`);
903
+ const importedVersion = await rcloneAsmrSyncDown(db, remoteFolderName);
904
+ await setLocalAsmrManifestVersion(importedVersion || remoteVersion);
905
+ notifySyncEnd();
906
+ log.info('Rclone ASMR sync down complete.');
907
+ return;
908
+ }
909
+ }
910
+ else {
911
+ log.info('Local ASMR DB is up to date.');
912
+ }
913
+ }
914
+ catch (err) {
915
+ notifySyncEnd();
916
+ log.error({ err }, 'ASMR sync boot error.');
917
+ }
918
+ finally {
919
+ isSyncing = false;
920
+ syncMutex.unlock();
921
+ }
922
+ }
923
+ export async function asmrSyncUp(db, remoteFolderName) {
924
+ if (activeProvider === 'none')
925
+ return;
926
+ await syncMutex.lock();
927
+ if (isSyncing) {
928
+ syncMutex.unlock();
929
+ return;
930
+ }
931
+ isSyncing = true;
932
+ try {
933
+ const localVersion = await getLocalAsmrManifestVersion();
934
+ const { version: remoteVersion } = await getAsmrRemoteManifestVersion(remoteFolderName);
935
+ if (localVersion > remoteVersion) {
936
+ notifySyncStart(`Syncing ASMR up (Local v${localVersion})`);
937
+ if (activeProvider === 'github') {
938
+ if (!githubSyncService.isAuthenticated())
939
+ return;
940
+ await githubSyncService.syncAsmrUp(db);
941
+ }
942
+ else if (activeProvider === 'google') {
943
+ if (!googleDriveService.isAuthenticated())
944
+ return;
945
+ await googleDriveService.syncAsmrUp(db);
946
+ }
947
+ else if (activeProvider === 'rclone') {
948
+ await rcloneAsmrSyncUp(db, remoteFolderName);
949
+ }
950
+ notifySyncEnd();
951
+ log.info('ASMR sync up complete.');
952
+ }
953
+ else {
954
+ log.info('No ASMR changes to sync up or remote is newer.');
955
+ }
956
+ }
957
+ catch (err) {
958
+ notifySyncEnd();
959
+ log.error({ err }, 'ASMR sync up failed.');
960
+ }
961
+ finally {
962
+ isSyncing = false;
963
+ syncMutex.unlock();
964
+ }
965
+ }
966
+ export async function initializeTvDatabase(dbPath) {
967
+ try {
968
+ const db = await DatabaseWrapper.create(dbPath);
969
+ db.configure('busyTimeout', 5000);
970
+ db.run('PRAGMA journal_mode = WAL;');
971
+ db.run('PRAGMA synchronous = NORMAL;');
972
+ db.run('PRAGMA cache_size = -20000;');
973
+ db.run('PRAGMA temp_store = MEMORY;');
974
+ db.run('PRAGMA mmap_size = 268435456;');
975
+ db.run('PRAGMA foreign_keys = ON;');
976
+ db.run(`CREATE TABLE IF NOT EXISTS tv_library (id TEXT PRIMARY KEY, tmdbId INTEGER NOT NULL, mediaType TEXT NOT NULL, title TEXT, poster TEXT, backdrop TEXT, year TEXT, overview TEXT, status TEXT DEFAULT 'Watching', adult INTEGER DEFAULT 0, lastSeason INTEGER, lastEpisode INTEGER, updatedAt INTEGER)`);
977
+ db.run(`CREATE TABLE IF NOT EXISTS tv_progress (mediaId TEXT NOT NULL, season INTEGER NOT NULL, episode INTEGER NOT NULL, currentTime REAL DEFAULT 0, duration REAL DEFAULT 0, updatedAt INTEGER, PRIMARY KEY (mediaId, season, episode))`);
978
+ db.run(`CREATE TABLE IF NOT EXISTS sync_metadata (key TEXT PRIMARY KEY, value INTEGER)`);
979
+ db.run(`INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('db_version', 1)`);
980
+ db.run(`CREATE INDEX IF NOT EXISTS idx_tv_library_status ON tv_library(status)`);
981
+ db.run(`CREATE INDEX IF NOT EXISTS idx_tv_library_tmdb ON tv_library(tmdbId, mediaType)`);
982
+ db.run(`CREATE INDEX IF NOT EXISTS idx_tv_progress_media ON tv_progress(mediaId, updatedAt)`);
983
+ const tvProgressColumns = db.all(`PRAGMA table_info(tv_progress)`);
984
+ if (!tvProgressColumns.some((c) => c.name === 'title')) {
985
+ db.run(`ALTER TABLE tv_progress ADD COLUMN title TEXT`);
986
+ }
987
+ if (!tvProgressColumns.some((c) => c.name === 'poster')) {
988
+ db.run(`ALTER TABLE tv_progress ADD COLUMN poster TEXT`);
989
+ }
990
+ if (!tvProgressColumns.some((c) => c.name === 'backdrop')) {
991
+ db.run(`ALTER TABLE tv_progress ADD COLUMN backdrop TEXT`);
992
+ }
993
+ if (!tvProgressColumns.some((c) => c.name === 'year')) {
994
+ db.run(`ALTER TABLE tv_progress ADD COLUMN year TEXT`);
995
+ }
996
+ if (!tvProgressColumns.some((c) => c.name === 'overview')) {
997
+ db.run(`ALTER TABLE tv_progress ADD COLUMN overview TEXT`);
998
+ }
999
+ if (!tvProgressColumns.some((c) => c.name === 'tmdbId')) {
1000
+ db.run(`ALTER TABLE tv_progress ADD COLUMN tmdbId INTEGER`);
1001
+ }
1002
+ if (!tvProgressColumns.some((c) => c.name === 'mediaType')) {
1003
+ db.run(`ALTER TABLE tv_progress ADD COLUMN mediaType TEXT`);
1004
+ }
1005
+ if (!tvProgressColumns.some((c) => c.name === 'adult')) {
1006
+ db.run(`ALTER TABLE tv_progress ADD COLUMN adult INTEGER`);
1007
+ }
1008
+ db.run(`UPDATE OR IGNORE tv_progress SET mediaId = REPLACE(mediaId, '-', ':') WHERE mediaId LIKE 'tv-%' OR mediaId LIKE 'movie-%'`);
1009
+ db.run(`DELETE FROM tv_progress WHERE mediaId LIKE 'tv-%' OR mediaId LIKE 'movie-%'`);
1010
+ return db;
1011
+ }
1012
+ catch (err) {
1013
+ log.error({ err }, 'TV database opening error');
1014
+ throw err;
1015
+ }
1016
+ }
1017
+ export async function initializeAsmrDatabase(dbPath) {
1018
+ try {
1019
+ const db = await DatabaseWrapper.create(dbPath);
1020
+ db.configure('busyTimeout', 5000);
1021
+ db.run('PRAGMA journal_mode = WAL;');
1022
+ db.run('PRAGMA synchronous = NORMAL;');
1023
+ db.run('PRAGMA cache_size = -20000;');
1024
+ db.run('PRAGMA temp_store = MEMORY;');
1025
+ db.run('PRAGMA mmap_size = 268435456;');
1026
+ db.run('PRAGMA foreign_keys = ON;');
1027
+ db.run(`CREATE TABLE IF NOT EXISTS asmr_library (id TEXT PRIMARY KEY, rjCode TEXT NOT NULL, title TEXT, thumbnail TEXT, status TEXT DEFAULT 'Listening', isAdult INTEGER DEFAULT 0, lastTrackIndex INTEGER, lastTrackLabel TEXT, lastPosition REAL, updatedAt INTEGER)`);
1028
+ db.run(`CREATE TABLE IF NOT EXISTS asmr_progress (workId TEXT NOT NULL, trackIndex INTEGER NOT NULL, trackLabel TEXT, currentTime REAL DEFAULT 0, duration REAL DEFAULT 0, updatedAt INTEGER, PRIMARY KEY (workId, trackIndex))`);
1029
+ db.run(`CREATE TABLE IF NOT EXISTS sync_metadata (key TEXT PRIMARY KEY, value INTEGER)`);
1030
+ db.run(`INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('db_version', 1)`);
1031
+ db.run(`CREATE INDEX IF NOT EXISTS idx_asmr_library_status ON asmr_library(status)`);
1032
+ db.run(`CREATE INDEX IF NOT EXISTS idx_asmr_library_rjcode ON asmr_library(rjCode)`);
1033
+ db.run(`CREATE INDEX IF NOT EXISTS idx_asmr_progress_work ON asmr_progress(workId, updatedAt)`);
1034
+ const asmrProgressColumns = db.all(`PRAGMA table_info(asmr_progress)`);
1035
+ if (!asmrProgressColumns.some((c) => c.name === 'title')) {
1036
+ db.run(`ALTER TABLE asmr_progress ADD COLUMN title TEXT`);
1037
+ }
1038
+ if (!asmrProgressColumns.some((c) => c.name === 'thumbnail')) {
1039
+ db.run(`ALTER TABLE asmr_progress ADD COLUMN thumbnail TEXT`);
1040
+ }
1041
+ if (!asmrProgressColumns.some((c) => c.name === 'rjCode')) {
1042
+ db.run(`ALTER TABLE asmr_progress ADD COLUMN rjCode TEXT`);
1043
+ }
1044
+ if (!asmrProgressColumns.some((c) => c.name === 'isAdult')) {
1045
+ db.run(`ALTER TABLE asmr_progress ADD COLUMN isAdult INTEGER`);
1046
+ }
1047
+ return db;
1048
+ }
1049
+ catch (err) {
1050
+ log.error({ err }, 'ASMR database opening error');
1051
+ throw err;
1052
+ }
1053
+ }
1054
+ export async function initializeDatabase(dbPath) {
1055
+ try {
1056
+ const db = await DatabaseWrapper.create(dbPath);
1057
+ db.configure('busyTimeout', 5000);
1058
+ db.run('PRAGMA journal_mode = WAL;');
1059
+ db.run('PRAGMA synchronous = NORMAL;');
1060
+ db.run('PRAGMA cache_size = -20000;');
1061
+ db.run('PRAGMA temp_store = MEMORY;');
1062
+ db.run('PRAGMA mmap_size = 268435456;');
1063
+ db.run('PRAGMA foreign_keys = ON;');
1064
+ db.run(`CREATE TABLE IF NOT EXISTS watchlist (id TEXT NOT NULL, name TEXT, thumbnail TEXT, status TEXT, nativeName TEXT, englishName TEXT, type TEXT, PRIMARY KEY (id))`);
1065
+ db.run(`CREATE TABLE IF NOT EXISTS watched_episodes (showId TEXT NOT NULL, episodeNumber TEXT NOT NULL, watchedAt DATETIME DEFAULT CURRENT_TIMESTAMP, currentTime REAL DEFAULT 0, duration REAL DEFAULT 0, PRIMARY KEY (showId, episodeNumber))`);
1066
+ db.run(`CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY, showId TEXT NOT NULL, episodeNumber TEXT NOT NULL, queue_order INTEGER NOT NULL)`);
1067
+ db.run(`CREATE TABLE IF NOT EXISTS settings (key TEXT NOT NULL, value TEXT, PRIMARY KEY (key))`);
1068
+ db.run(`CREATE TABLE IF NOT EXISTS shows_meta (id TEXT PRIMARY KEY, name TEXT, thumbnail TEXT, nativeName TEXT, englishName TEXT, episodeCount INTEGER, status TEXT, genres TEXT, popularityScore INTEGER, type TEXT)`);
1069
+ db.run(`CREATE TABLE IF NOT EXISTS dismissed_notifications (showId TEXT NOT NULL, episodeNumber TEXT NOT NULL, dismissedAt DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (showId, episodeNumber))`);
1070
+ db.run(`CREATE TABLE IF NOT EXISTS discovered_notifications (showId TEXT NOT NULL, episodeNumber TEXT NOT NULL, discoveredAt DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (showId, episodeNumber))`);
1071
+ db.run(`CREATE TABLE IF NOT EXISTS sync_metadata (key TEXT PRIMARY KEY, value INTEGER)`);
1072
+ db.run(`INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('db_version', 1)`);
1073
+ db.run(`CREATE TABLE IF NOT EXISTS legacy_id_mapping (legacyId TEXT PRIMARY KEY, numericId TEXT)`);
1074
+ db.run(`CREATE TABLE IF NOT EXISTS temp_show_ids (id TEXT PRIMARY KEY, provider TEXT NOT NULL, nativeId TEXT NOT NULL, title TEXT NOT NULL, thumbnail TEXT, createdAt INTEGER NOT NULL)`);
1075
+ db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_temp_show_ids_provider_native ON temp_show_ids(provider, nativeId)`);
1076
+ try {
1077
+ const purged = TempShowIdsRepository.purge(db);
1078
+ if (purged > 0)
1079
+ logger.info({ purged }, 'Purged stale temp show ids on boot');
1080
+ }
1081
+ catch (e) {
1082
+ logger.warn({ err: e }, 'Temp show purge on boot failed');
1083
+ }
1084
+ try {
1085
+ const pruned = malCachePruneExpired();
1086
+ if (pruned > 0)
1087
+ logger.info({ pruned }, 'Pruned expired mal cache rows on boot');
1088
+ }
1089
+ catch (e) {
1090
+ logger.warn({ err: e }, 'Mal cache prune on boot failed');
1091
+ }
1092
+ db.run(`CREATE INDEX IF NOT EXISTS idx_watched_episodes_showId ON watched_episodes(showId)`);
1093
+ db.run(`CREATE INDEX IF NOT EXISTS idx_watched_episodes_showId_episodeNumber ON watched_episodes(showId, episodeNumber)`);
1094
+ db.run(`CREATE INDEX IF NOT EXISTS idx_watched_episodes_watchedAt ON watched_episodes(watchedAt)`);
1095
+ db.run(`CREATE INDEX IF NOT EXISTS idx_watchlist_status ON watchlist(status)`);
1096
+ db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_queue_show_episode ON queue(showId, episodeNumber)`);
1097
+ db.run(`CREATE INDEX IF NOT EXISTS idx_queue_order ON queue(queue_order)`);
1098
+ db.run('DELETE FROM dismissed_notifications WHERE showId NOT IN (SELECT id FROM watchlist)');
1099
+ db.run('DELETE FROM discovered_notifications WHERE showId NOT IN (SELECT id FROM watchlist)');
1100
+ db.run('DELETE FROM legacy_id_mapping WHERE legacyId NOT IN (SELECT id FROM watchlist) AND numericId NOT IN (SELECT id FROM watchlist)');
1101
+ db.run('DELETE FROM dismissed_notifications WHERE EXISTS (SELECT 1 FROM watched_episodes we WHERE we.showId = dismissed_notifications.showId AND we.episodeNumber = dismissed_notifications.episodeNumber)');
1102
+ db.run('DELETE FROM discovered_notifications WHERE EXISTS (SELECT 1 FROM watched_episodes we WHERE we.showId = discovered_notifications.showId AND we.episodeNumber = discovered_notifications.episodeNumber)');
1103
+ const addCol = (tbl, col, type) => {
1104
+ const columns = db.all(`PRAGMA table_info(${tbl})`);
1105
+ if (!columns.some((c) => c.name === col))
1106
+ db.run(`ALTER TABLE ${tbl} ADD COLUMN ${col} ${type}`);
1107
+ };
1108
+ addCol('watchlist', 'nativeName', 'TEXT');
1109
+ addCol('watchlist', 'englishName', 'TEXT');
1110
+ addCol('shows_meta', 'nativeName', 'TEXT');
1111
+ addCol('shows_meta', 'englishName', 'TEXT');
1112
+ addCol('shows_meta', 'episodeCount', 'INTEGER');
1113
+ addCol('shows_meta', 'status', 'TEXT');
1114
+ addCol('shows_meta', 'genres', 'TEXT');
1115
+ addCol('shows_meta', 'popularityScore', 'INTEGER');
1116
+ addCol('watchlist', 'type', 'TEXT');
1117
+ addCol('shows_meta', 'type', 'TEXT');
1118
+ addCol('shows_meta', 'anilistId', 'INTEGER');
1119
+ addCol('shows_meta', 'isAdult', 'INTEGER');
1120
+ addCol('shows_meta', 'episodeDuration', 'INTEGER');
1121
+ await db.saveNow();
1122
+ return db;
1123
+ }
1124
+ catch (err) {
1125
+ log.error({ err }, 'Database opening error');
1126
+ throw err;
1127
+ }
1128
+ }