@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,512 @@
1
+ import logger from './logger.js';
2
+ import path from 'path';
3
+ import { dbAll, dbGet } from './utils/db-utils.js';
4
+ import { isTempSyncRow } from './lib/temp-ids.js';
5
+ import { updateEnvFile } from './utils/env.utils.js';
6
+ import { CONFIG } from './config.js';
7
+ import { SHIPPED_DEFAULTS } from './shipped-defaults.js';
8
+ import { exportTables, importTables, MANGA_SYNC_TABLES, TV_SYNC_TABLES, ASMR_SYNC_TABLES, normalizePayload as normalizeGenericPayload, readPayloadVersion, } from './sync-payload.js';
9
+ const log = logger.child({ module: 'GitHubSync' });
10
+ const REPO_NAME = 'dango-sync-data';
11
+ const LEGACY_REPO_NAME = 'aniweb-sync-data';
12
+ const GITHUB_SCOPES = ['repo', 'delete_repo'];
13
+ const GITHUB_API_HEADERS = {
14
+ accept: 'application/vnd.github+json',
15
+ 'x-github-api-version': '2026-03-10',
16
+ };
17
+ const SYNC_TABLES = [
18
+ 'watchlist',
19
+ 'watched_episodes',
20
+ 'queue',
21
+ 'settings',
22
+ 'shows_meta',
23
+ 'sync_metadata',
24
+ 'dismissed_notifications',
25
+ 'discovered_notifications',
26
+ ];
27
+ const nativeImport = new Function('specifier', 'return import(specifier)');
28
+ function getGitHubClientId() {
29
+ const id = process.env.GITHUB_CLIENT_ID || SHIPPED_DEFAULTS.GITHUB_CLIENT_ID || '';
30
+ if (!id) {
31
+ throw new Error('GITHUB_CLIENT_ID is not configured');
32
+ }
33
+ return id;
34
+ }
35
+ function getSyncFilename() {
36
+ return CONFIG.IS_DEV ? 'sync.dev.json' : 'sync.json';
37
+ }
38
+ function getMangaSyncFilename() {
39
+ return CONFIG.IS_DEV ? 'manga.sync.dev.json' : 'manga.sync.json';
40
+ }
41
+ function getTvSyncFilename() {
42
+ return CONFIG.IS_DEV ? 'tv.sync.dev.json' : 'tv.sync.json';
43
+ }
44
+ function getAsmrSyncFilename() {
45
+ return CONFIG.IS_DEV ? 'asmr.sync.dev.json' : 'asmr.sync.json';
46
+ }
47
+ async function loadOctokit(token) {
48
+ const { Octokit } = await nativeImport('@octokit/rest');
49
+ return new Octokit({
50
+ auth: token,
51
+ request: {
52
+ headers: GITHUB_API_HEADERS,
53
+ },
54
+ });
55
+ }
56
+ function getErrorStatus(error) {
57
+ if (typeof error === 'object' && error && 'status' in error) {
58
+ return Number(error.status);
59
+ }
60
+ return undefined;
61
+ }
62
+ function quoteIdentifier(identifier) {
63
+ return `"${identifier.replace(/"/g, '""')}"`;
64
+ }
65
+ function getRowsFromAll(db, sql) {
66
+ return dbAll(db, sql);
67
+ }
68
+ function readVersion(payload) {
69
+ const versionRow = payload.tables.sync_metadata.find((row) => row.key === 'db_version');
70
+ const value = versionRow?.value;
71
+ return typeof value === 'number' ? value : Number(value || payload.version || 0);
72
+ }
73
+ function normalizePayload(input) {
74
+ if (!input || typeof input !== 'object' || !('tables' in input)) {
75
+ throw new Error('Invalid GitHub sync payload.');
76
+ }
77
+ const payload = input;
78
+ for (const table of SYNC_TABLES) {
79
+ if (!Array.isArray(payload.tables?.[table])) {
80
+ throw new Error(`Invalid GitHub sync payload: missing ${table}.`);
81
+ }
82
+ }
83
+ return payload;
84
+ }
85
+ class GitHubSyncService {
86
+ deviceState = { status: 'idle' };
87
+ devicePromise = null;
88
+ isAuthenticated() {
89
+ return !!process.env.GITHUB_TOKEN;
90
+ }
91
+ async migrateFromAniWebSync() {
92
+ if (!this.isAuthenticated())
93
+ return;
94
+ try {
95
+ const octokit = await this.getOctokit();
96
+ const { data: user } = await octokit.rest.users.getAuthenticated({
97
+ headers: GITHUB_API_HEADERS,
98
+ });
99
+ try {
100
+ await octokit.rest.repos.get({
101
+ owner: user.login,
102
+ repo: LEGACY_REPO_NAME,
103
+ headers: GITHUB_API_HEADERS,
104
+ });
105
+ }
106
+ catch (err) {
107
+ if (getErrorStatus(err) === 404) {
108
+ log.info('No legacy aniweb-sync-data repo found. Skipping migration.');
109
+ return;
110
+ }
111
+ throw err;
112
+ }
113
+ log.info('Found legacy aniweb-sync-data repo. Renaming to dango-sync-data...');
114
+ const existingNew = await octokit.rest.repos
115
+ .get({
116
+ owner: user.login,
117
+ repo: REPO_NAME,
118
+ headers: GITHUB_API_HEADERS,
119
+ })
120
+ .catch(() => null);
121
+ if (existingNew) {
122
+ log.info('dango-sync-data repo already exists. Deleting legacy repo.');
123
+ await octokit.rest.repos.delete({
124
+ owner: user.login,
125
+ repo: LEGACY_REPO_NAME,
126
+ headers: GITHUB_API_HEADERS,
127
+ });
128
+ log.info('Deleted legacy aniweb-sync-data repo.');
129
+ return;
130
+ }
131
+ await octokit.rest.repos.update({
132
+ owner: user.login,
133
+ repo: LEGACY_REPO_NAME,
134
+ name: REPO_NAME,
135
+ description: 'Private dango synchronization data.',
136
+ headers: GITHUB_API_HEADERS,
137
+ });
138
+ log.info('Renamed aniweb-sync-data to dango-sync-data.');
139
+ }
140
+ catch (err) {
141
+ log.error({ err }, 'GitHub sync migration failed');
142
+ }
143
+ }
144
+ getDeviceState() {
145
+ return this.deviceState;
146
+ }
147
+ async startDeviceAuth(db, runSyncSequence) {
148
+ if (this.isAuthenticated()) {
149
+ const user = await this.getUserProfile();
150
+ if (user) {
151
+ this.deviceState = {
152
+ status: 'success',
153
+ user: user,
154
+ };
155
+ const { updateEnvFile } = await import('./utils/env.utils.js');
156
+ await updateEnvFile({ SYNC_PROVIDER: 'github' });
157
+ await runSyncSequence(db, 'github');
158
+ return this.deviceState;
159
+ }
160
+ else {
161
+ log.warn('Saved GitHub token is invalid or expired. Clearing for new auth.');
162
+ await this.logout();
163
+ }
164
+ }
165
+ if (this.deviceState.status === 'pending') {
166
+ return this.deviceState;
167
+ }
168
+ this.deviceState = { status: 'pending' };
169
+ let resolveVerification;
170
+ const verificationReady = new Promise((resolve) => {
171
+ resolveVerification = resolve;
172
+ });
173
+ this.devicePromise = this.runDeviceAuth(db, runSyncSequence, resolveVerification);
174
+ await verificationReady;
175
+ return this.deviceState;
176
+ }
177
+ async getUserProfile() {
178
+ if (!process.env.GITHUB_TOKEN)
179
+ return null;
180
+ try {
181
+ const octokit = await loadOctokit(process.env.GITHUB_TOKEN);
182
+ const { data } = await octokit.rest.users.getAuthenticated({
183
+ headers: GITHUB_API_HEADERS,
184
+ });
185
+ return {
186
+ login: data.login,
187
+ name: data.name,
188
+ avatarUrl: data.avatar_url,
189
+ };
190
+ }
191
+ catch (err) {
192
+ if (getErrorStatus(err) === 401) {
193
+ log.warn('GitHub token is invalid or expired. Logging out.');
194
+ await this.logout();
195
+ }
196
+ return null;
197
+ }
198
+ }
199
+ async logout() {
200
+ await updateEnvFile({ GITHUB_TOKEN: '' });
201
+ delete process.env.GITHUB_TOKEN;
202
+ this.deviceState = { status: 'idle' };
203
+ }
204
+ async getRemoteVersion() {
205
+ const payload = await this.fetchSyncPayload();
206
+ return payload ? readVersion(payload) : 0;
207
+ }
208
+ async getMangaRemoteVersion() {
209
+ const payload = await this.fetchMangaSyncPayload();
210
+ return payload ? readPayloadVersion(payload) : 0;
211
+ }
212
+ async getTvRemoteVersion() {
213
+ const payload = await this.fetchTvSyncPayload();
214
+ return payload ? readPayloadVersion(payload) : 0;
215
+ }
216
+ async getAsmrRemoteVersion() {
217
+ const payload = await this.fetchAsmrSyncPayload();
218
+ return payload ? readPayloadVersion(payload) : 0;
219
+ }
220
+ async syncUp(db) {
221
+ const payload = await this.exportDatabase(db);
222
+ const octokit = await this.getOctokit();
223
+ const owner = await this.ensureRepo(octokit);
224
+ const existing = await this.getSyncFile(octokit, owner);
225
+ const content = Buffer.from(JSON.stringify(payload, null, 2), 'utf8').toString('base64');
226
+ await octokit.rest.repos.createOrUpdateFileContents({
227
+ owner,
228
+ repo: REPO_NAME,
229
+ path: getSyncFilename(),
230
+ message: `Sync dango data v${payload.version}`,
231
+ content,
232
+ sha: existing?.sha,
233
+ headers: GITHUB_API_HEADERS,
234
+ });
235
+ }
236
+ async syncDown(db) {
237
+ const payload = await this.fetchSyncPayload();
238
+ if (!payload) {
239
+ return 0;
240
+ }
241
+ this.importDatabase(db, payload);
242
+ return readVersion(payload);
243
+ }
244
+ async syncMangaUp(db) {
245
+ const payload = exportTables(db, MANGA_SYNC_TABLES);
246
+ const octokit = await this.getOctokit();
247
+ const owner = await this.ensureRepo(octokit);
248
+ const existing = await this.getSyncFile(octokit, owner, getMangaSyncFilename());
249
+ const content = Buffer.from(JSON.stringify(payload, null, 2), 'utf8').toString('base64');
250
+ await octokit.rest.repos.createOrUpdateFileContents({
251
+ owner,
252
+ repo: REPO_NAME,
253
+ path: getMangaSyncFilename(),
254
+ message: `Sync dango manga data v${payload.version}`,
255
+ content,
256
+ sha: existing?.sha,
257
+ headers: GITHUB_API_HEADERS,
258
+ });
259
+ }
260
+ async syncMangaDown(db) {
261
+ const payload = await this.fetchMangaSyncPayload();
262
+ if (!payload) {
263
+ return 0;
264
+ }
265
+ importTables(db, MANGA_SYNC_TABLES, payload, {
266
+ libraryTables: ['manga_library', 'manga_progress'],
267
+ backupName: 'pre-sync-manga-backup.db',
268
+ });
269
+ return readPayloadVersion(payload);
270
+ }
271
+ async syncTvUp(db) {
272
+ const payload = exportTables(db, TV_SYNC_TABLES);
273
+ const octokit = await this.getOctokit();
274
+ const owner = await this.ensureRepo(octokit);
275
+ const existing = await this.getSyncFile(octokit, owner, getTvSyncFilename());
276
+ const content = Buffer.from(JSON.stringify(payload, null, 2), 'utf8').toString('base64');
277
+ await octokit.rest.repos.createOrUpdateFileContents({
278
+ owner,
279
+ repo: REPO_NAME,
280
+ path: getTvSyncFilename(),
281
+ message: `Sync dango TV data v${payload.version}`,
282
+ content,
283
+ sha: existing?.sha,
284
+ headers: GITHUB_API_HEADERS,
285
+ });
286
+ }
287
+ async syncTvDown(db) {
288
+ const payload = await this.fetchTvSyncPayload();
289
+ if (!payload) {
290
+ return 0;
291
+ }
292
+ importTables(db, TV_SYNC_TABLES, payload, {
293
+ libraryTables: ['tv_library', 'tv_progress'],
294
+ backupName: 'pre-sync-tv-backup.db',
295
+ });
296
+ return readPayloadVersion(payload);
297
+ }
298
+ async syncAsmrUp(db) {
299
+ const payload = exportTables(db, ASMR_SYNC_TABLES);
300
+ const octokit = await this.getOctokit();
301
+ const owner = await this.ensureRepo(octokit);
302
+ const existing = await this.getSyncFile(octokit, owner, getAsmrSyncFilename());
303
+ const content = Buffer.from(JSON.stringify(payload, null, 2), 'utf8').toString('base64');
304
+ await octokit.rest.repos.createOrUpdateFileContents({
305
+ owner,
306
+ repo: REPO_NAME,
307
+ path: getAsmrSyncFilename(),
308
+ message: `Sync dango ASMR data v${payload.version}`,
309
+ content,
310
+ sha: existing?.sha,
311
+ headers: GITHUB_API_HEADERS,
312
+ });
313
+ }
314
+ async syncAsmrDown(db) {
315
+ const payload = await this.fetchAsmrSyncPayload();
316
+ if (!payload) {
317
+ return 0;
318
+ }
319
+ importTables(db, ASMR_SYNC_TABLES, payload, {
320
+ libraryTables: ['asmr_library', 'asmr_progress'],
321
+ backupName: 'pre-sync-asmr-backup.db',
322
+ });
323
+ return readPayloadVersion(payload);
324
+ }
325
+ async runDeviceAuth(db, runSyncSequence, resolveVerification) {
326
+ try {
327
+ const { createOAuthDeviceAuth } = await nativeImport('@octokit/auth-oauth-device');
328
+ const auth = createOAuthDeviceAuth({
329
+ clientId: getGitHubClientId(),
330
+ scopes: GITHUB_SCOPES,
331
+ onVerification: (verification) => {
332
+ this.deviceState = {
333
+ status: 'pending',
334
+ verification: {
335
+ user_code: verification.user_code,
336
+ verification_uri: verification.verification_uri,
337
+ expires_in: verification.expires_in,
338
+ interval: verification.interval,
339
+ },
340
+ };
341
+ resolveVerification();
342
+ },
343
+ });
344
+ const authentication = await auth({ type: 'oauth' });
345
+ await updateEnvFile({ GITHUB_TOKEN: authentication.token, SYNC_PROVIDER: 'github' });
346
+ process.env.GITHUB_TOKEN = authentication.token;
347
+ const user = await this.getUserProfile();
348
+ this.deviceState = {
349
+ status: 'success',
350
+ user: user || undefined,
351
+ };
352
+ try {
353
+ await runSyncSequence(db, 'github');
354
+ }
355
+ catch (err) {
356
+ log.error({ err }, 'Post-GitHub-login sync failed');
357
+ }
358
+ }
359
+ catch (err) {
360
+ this.deviceState = {
361
+ status: 'error',
362
+ error: err instanceof Error ? err.message : 'GitHub device authentication failed.',
363
+ };
364
+ resolveVerification();
365
+ log.error({ err }, 'GitHub device authentication failed');
366
+ }
367
+ finally {
368
+ this.devicePromise = null;
369
+ }
370
+ }
371
+ async getOctokit() {
372
+ if (!process.env.GITHUB_TOKEN) {
373
+ throw new Error('GitHub token is not configured.');
374
+ }
375
+ return loadOctokit(process.env.GITHUB_TOKEN);
376
+ }
377
+ async ensureRepo(octokit) {
378
+ const { data: user } = await octokit.rest.users.getAuthenticated({
379
+ headers: GITHUB_API_HEADERS,
380
+ });
381
+ try {
382
+ await octokit.rest.repos.get({
383
+ owner: user.login,
384
+ repo: REPO_NAME,
385
+ headers: GITHUB_API_HEADERS,
386
+ });
387
+ }
388
+ catch (err) {
389
+ if (getErrorStatus(err) !== 404) {
390
+ throw err;
391
+ }
392
+ await octokit.rest.repos.createForAuthenticatedUser({
393
+ name: REPO_NAME,
394
+ private: true,
395
+ auto_init: true,
396
+ description: 'Private dango synchronization data.',
397
+ headers: GITHUB_API_HEADERS,
398
+ });
399
+ }
400
+ return user.login;
401
+ }
402
+ async getSyncFile(octokit, owner, filePath = getSyncFilename()) {
403
+ try {
404
+ const response = await octokit.rest.repos.getContent({
405
+ owner,
406
+ repo: REPO_NAME,
407
+ path: filePath,
408
+ headers: GITHUB_API_HEADERS,
409
+ });
410
+ const data = response.data;
411
+ if (data.type !== 'file' || !data.content || !data.sha) {
412
+ return null;
413
+ }
414
+ return {
415
+ content: Buffer.from(data.content, 'base64').toString('utf8'),
416
+ sha: data.sha,
417
+ };
418
+ }
419
+ catch (err) {
420
+ if (getErrorStatus(err) === 404) {
421
+ return null;
422
+ }
423
+ throw err;
424
+ }
425
+ }
426
+ async fetchSyncPayload() {
427
+ const octokit = await this.getOctokit();
428
+ const owner = await this.ensureRepo(octokit);
429
+ const file = await this.getSyncFile(octokit, owner);
430
+ if (!file) {
431
+ return null;
432
+ }
433
+ return normalizePayload(JSON.parse(file.content));
434
+ }
435
+ async fetchMangaSyncPayload() {
436
+ const octokit = await this.getOctokit();
437
+ const owner = await this.ensureRepo(octokit);
438
+ const file = await this.getSyncFile(octokit, owner, getMangaSyncFilename());
439
+ if (!file) {
440
+ return null;
441
+ }
442
+ return normalizeGenericPayload(JSON.parse(file.content), MANGA_SYNC_TABLES, 'GitHub manga');
443
+ }
444
+ async fetchTvSyncPayload() {
445
+ const octokit = await this.getOctokit();
446
+ const owner = await this.ensureRepo(octokit);
447
+ const file = await this.getSyncFile(octokit, owner, getTvSyncFilename());
448
+ if (!file) {
449
+ return null;
450
+ }
451
+ return normalizeGenericPayload(JSON.parse(file.content), TV_SYNC_TABLES, 'GitHub TV');
452
+ }
453
+ async fetchAsmrSyncPayload() {
454
+ const octokit = await this.getOctokit();
455
+ const owner = await this.ensureRepo(octokit);
456
+ const file = await this.getSyncFile(octokit, owner, getAsmrSyncFilename());
457
+ if (!file) {
458
+ return null;
459
+ }
460
+ return normalizeGenericPayload(JSON.parse(file.content), ASMR_SYNC_TABLES, 'GitHub ASMR');
461
+ }
462
+ async exportDatabase(db) {
463
+ const tables = {};
464
+ for (const table of SYNC_TABLES) {
465
+ const rows = await getRowsFromAll(db, `SELECT * FROM ${quoteIdentifier(table)}`);
466
+ tables[table] = rows.filter((row) => !isTempSyncRow(row));
467
+ }
468
+ return {
469
+ version: readVersion({ version: 0, exportedAt: '', tables }),
470
+ exportedAt: new Date().toISOString(),
471
+ tables,
472
+ };
473
+ }
474
+ importDatabase(db, payload) {
475
+ const remoteLibraryRows = (payload.tables.watchlist?.length ?? 0) + (payload.tables.watched_episodes?.length ?? 0);
476
+ const localLibraryRows = dbGet(db, 'SELECT (SELECT COUNT(*) FROM watchlist) + (SELECT COUNT(*) FROM watched_episodes) AS n')?.n ?? 0;
477
+ if (remoteLibraryRows === 0 && localLibraryRows > 0) {
478
+ throw new Error('Sync down refused: remote library is empty while local library has data');
479
+ }
480
+ try {
481
+ db.backup(path.join(CONFIG.ROOT, 'pre-sync-backup.db'));
482
+ }
483
+ catch (err) {
484
+ log.warn({ err }, 'Pre-sync backup failed, continuing without backup');
485
+ }
486
+ const localColumns = new Map();
487
+ for (const table of SYNC_TABLES) {
488
+ const cols = db.all(`PRAGMA table_info("${table}")`);
489
+ localColumns.set(table, new Set(cols.map((c) => c.name)));
490
+ }
491
+ db.serialize(() => {
492
+ for (const table of SYNC_TABLES) {
493
+ db.run(`DELETE FROM ${quoteIdentifier(table)}`);
494
+ }
495
+ for (const table of SYNC_TABLES) {
496
+ const known = localColumns.get(table);
497
+ for (const row of payload.tables[table]) {
498
+ if (isTempSyncRow(row))
499
+ continue;
500
+ const columns = Object.keys(row).filter((c) => known?.has(c));
501
+ if (columns.length === 0)
502
+ continue;
503
+ const columnSql = columns.map(quoteIdentifier).join(', ');
504
+ const placeholders = columns.map(() => '?').join(', ');
505
+ const values = columns.map((column) => row[column]);
506
+ db.run(`INSERT INTO ${quoteIdentifier(table)} (${columnSql}) VALUES (${placeholders})`, values);
507
+ }
508
+ }
509
+ });
510
+ }
511
+ }
512
+ export const githubSyncService = new GitHubSyncService();