@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,748 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Readable } from 'stream';
4
+ import { pipeline } from 'stream/promises';
5
+ import logger from './logger.js';
6
+ import { CONFIG } from './config.js';
7
+ import { dbAll, dbGet } from './utils/db-utils.js';
8
+ import { isTempSyncRow } from './lib/temp-ids.js';
9
+ import { fetchWithRetry, HttpError, decodeMaybeGzip, parseJsonBody } from './utils/http.utils.js';
10
+ import { exportTables, importTables, MANGA_SYNC_TABLES, TV_SYNC_TABLES, ASMR_SYNC_TABLES, normalizePayload as normalizeGenericPayload, readPayloadVersion, } from './sync-payload.js';
11
+ const GOOGLE_REQUEST_TIMEOUT_MS = 30000;
12
+ export class GoogleDriveService {
13
+ tokens = {};
14
+ folderIdCache = new Map();
15
+ constructor() {
16
+ // Client ID/secret now live in Cloudflare Worker (GOOGLE_AUTH_WORKER_URL).
17
+ // Legacy user-owned .env credentials are optional fallback only.
18
+ if (!CONFIG.GOOGLE_AUTH_WORKER_URL && !CONFIG.GOOGLE_CLIENT_ID) {
19
+ logger.error('GOOGLE_AUTH_WORKER_URL is missing and no GOOGLE_CLIENT_ID fallback!');
20
+ }
21
+ this.loadTokens();
22
+ }
23
+ useWorker() {
24
+ return !!CONFIG.GOOGLE_AUTH_WORKER_URL;
25
+ }
26
+ loadTokens() {
27
+ if (fs.existsSync(CONFIG.TOKEN_PATH)) {
28
+ try {
29
+ this.tokens = JSON.parse(fs.readFileSync(CONFIG.TOKEN_PATH, 'utf-8'));
30
+ }
31
+ catch (error) {
32
+ logger.error({ err: error }, 'Failed to load Google tokens');
33
+ }
34
+ }
35
+ }
36
+ saveTokens(tokens) {
37
+ const merged = { ...this.tokens, ...tokens };
38
+ if (merged.expires_in && !merged.expiry_date) {
39
+ merged.expiry_date = Date.now() + merged.expires_in * 1000;
40
+ }
41
+ this.tokens = merged;
42
+ try {
43
+ fs.writeFileSync(CONFIG.TOKEN_PATH, JSON.stringify(this.tokens));
44
+ }
45
+ catch (error) {
46
+ logger.error({ err: error }, 'Failed to save refreshed tokens');
47
+ }
48
+ }
49
+ getGoogleClientConfig() {
50
+ // Legacy fallback only. Preferred path is Worker (no secret in dango).
51
+ return {
52
+ clientId: CONFIG.GOOGLE_CLIENT_ID || '',
53
+ clientSecret: CONFIG.GOOGLE_CLIENT_SECRET || '',
54
+ };
55
+ }
56
+ async postJson(url, payload) {
57
+ const response = await fetchWithRetry(url, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify(payload),
61
+ }, { timeoutMs: GOOGLE_REQUEST_TIMEOUT_MS });
62
+ if (!response.ok)
63
+ throw new HttpError(response.status);
64
+ return parseJsonBody(response);
65
+ }
66
+ async postForm(url, params) {
67
+ const response = await fetchWithRetry(url, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70
+ body: params.toString(),
71
+ }, { timeoutMs: GOOGLE_REQUEST_TIMEOUT_MS });
72
+ if (!response.ok)
73
+ throw new HttpError(response.status);
74
+ return parseJsonBody(response);
75
+ }
76
+ async refreshViaWorker() {
77
+ if (!this.tokens.refresh_token)
78
+ return false;
79
+ try {
80
+ const data = await this.postJson(`${CONFIG.GOOGLE_AUTH_WORKER_URL}/refresh`, {
81
+ refresh_token: this.tokens.refresh_token,
82
+ });
83
+ this.saveTokens(data);
84
+ return true;
85
+ }
86
+ catch (error) {
87
+ if (error instanceof HttpError && (error.status === 400 || error.status === 401)) {
88
+ logger.warn('Worker refresh failed. Token may be revoked. Logging out.');
89
+ await this.logout();
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+ async refreshAccessToken() {
95
+ if (!this.tokens.refresh_token) {
96
+ throw new Error('Missing refresh token');
97
+ }
98
+ // Preferred: secret stays in Worker
99
+ if (this.useWorker()) {
100
+ await this.refreshViaWorker();
101
+ return;
102
+ }
103
+ const { clientId, clientSecret } = this.getGoogleClientConfig();
104
+ if (!clientId || !clientSecret) {
105
+ throw new Error('Google OAuth credentials are not configured');
106
+ }
107
+ const params = new URLSearchParams({
108
+ client_id: clientId,
109
+ client_secret: clientSecret,
110
+ refresh_token: this.tokens.refresh_token,
111
+ grant_type: 'refresh_token',
112
+ });
113
+ try {
114
+ const data = await this.postForm('https://oauth2.googleapis.com/token', params);
115
+ this.saveTokens(data);
116
+ }
117
+ catch (error) {
118
+ if (error instanceof HttpError && (error.status === 400 || error.status === 401)) {
119
+ logger.warn('Failed to refresh Google access token. Token may be revoked. Logging out.');
120
+ await this.logout();
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+ async ensureAccessToken() {
126
+ const expiresSoon = !this.tokens.expiry_date || Date.now() >= this.tokens.expiry_date - 60_000;
127
+ if (!this.tokens.access_token || expiresSoon) {
128
+ await this.refreshAccessToken();
129
+ }
130
+ }
131
+ withParams(url, params) {
132
+ if (!params)
133
+ return url;
134
+ const parsed = new URL(url);
135
+ for (const [key, value] of Object.entries(params)) {
136
+ if (value === undefined || value === null)
137
+ continue;
138
+ parsed.searchParams.set(key, String(value));
139
+ }
140
+ return parsed.toString();
141
+ }
142
+ serializeBody(data) {
143
+ if (data === undefined || data === null)
144
+ return {};
145
+ if (typeof data === 'string')
146
+ return { body: data };
147
+ if (data instanceof Uint8Array || data instanceof Readable) {
148
+ return { body: data, duplex: 'half' };
149
+ }
150
+ return { body: JSON.stringify(data) };
151
+ }
152
+ async googleRequest(config) {
153
+ await this.ensureAccessToken();
154
+ try {
155
+ const { body, duplex } = this.serializeBody(config.data);
156
+ const response = await fetchWithRetry(this.withParams(config.url, config.params), {
157
+ method: config.method,
158
+ headers: {
159
+ Authorization: `Bearer ${this.tokens.access_token}`,
160
+ ...(config.headers ?? {}),
161
+ },
162
+ ...(body !== undefined ? { body } : {}),
163
+ ...(duplex ? { duplex } : {}),
164
+ }, { timeoutMs: GOOGLE_REQUEST_TIMEOUT_MS });
165
+ if (!response.ok) {
166
+ try {
167
+ await response.body?.cancel();
168
+ }
169
+ catch {
170
+ // ignore
171
+ }
172
+ throw new HttpError(response.status);
173
+ }
174
+ if (config.responseType === 'stream') {
175
+ if (!response.body)
176
+ throw new HttpError(response.status, 'Empty upstream body');
177
+ return {
178
+ data: Readable.from(response.body),
179
+ status: response.status,
180
+ headers: response.headers,
181
+ };
182
+ }
183
+ if (config.responseType === 'text') {
184
+ return {
185
+ data: (await response.text()),
186
+ status: response.status,
187
+ headers: response.headers,
188
+ };
189
+ }
190
+ const text = decodeMaybeGzip(Buffer.from(await response.arrayBuffer())).toString('utf8');
191
+ return {
192
+ data: (text ? JSON.parse(text) : undefined),
193
+ status: response.status,
194
+ headers: response.headers,
195
+ };
196
+ }
197
+ catch (error) {
198
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
199
+ logger.warn('Google API request failed with auth error. Logging out.');
200
+ await this.logout();
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+ isAuthenticated() {
206
+ return !!this.tokens.refresh_token;
207
+ }
208
+ async getAuthUrl() {
209
+ // Preferred: Worker builds URL with bundled client_id, no secret needed here
210
+ if (this.useWorker()) {
211
+ try {
212
+ const url = new URL(`${CONFIG.GOOGLE_AUTH_WORKER_URL}/auth-url`);
213
+ url.searchParams.set('redirect_uri', CONFIG.GOOGLE_REDIRECT_URI);
214
+ const response = await fetchWithRetry(url, {}, { timeoutMs: GOOGLE_REQUEST_TIMEOUT_MS });
215
+ if (!response.ok)
216
+ throw new HttpError(response.status);
217
+ const data = await parseJsonBody(response);
218
+ if (data?.url)
219
+ return data.url;
220
+ }
221
+ catch (error) {
222
+ logger.error({ err: error }, 'Worker /auth-url failed, falling back to local');
223
+ }
224
+ }
225
+ const { clientId } = this.getGoogleClientConfig();
226
+ if (!clientId)
227
+ throw new Error('Google OAuth credentials are not configured');
228
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
229
+ url.searchParams.set('client_id', clientId);
230
+ url.searchParams.set('redirect_uri', CONFIG.GOOGLE_REDIRECT_URI);
231
+ url.searchParams.set('response_type', 'code');
232
+ url.searchParams.set('access_type', 'offline');
233
+ url.searchParams.set('prompt', 'consent');
234
+ url.searchParams.set('scope', CONFIG.GOOGLE_SCOPES.join(' '));
235
+ return url.toString();
236
+ }
237
+ async handleCallback(code) {
238
+ // Preferred: Worker exchanges code with secret, dango never sees secret
239
+ if (this.useWorker()) {
240
+ try {
241
+ const data = await this.postJson(`${CONFIG.GOOGLE_AUTH_WORKER_URL}/exchange`, { code, redirect_uri: CONFIG.GOOGLE_REDIRECT_URI });
242
+ this.saveTokens(data);
243
+ return this.tokens;
244
+ }
245
+ catch (error) {
246
+ logger.error({ err: error }, 'Worker /exchange failed');
247
+ throw error;
248
+ }
249
+ }
250
+ // Legacy fallback: user-owned client_id/secret in .env
251
+ const { clientId, clientSecret } = this.getGoogleClientConfig();
252
+ const params = new URLSearchParams({
253
+ code,
254
+ client_id: clientId,
255
+ client_secret: clientSecret,
256
+ redirect_uri: CONFIG.GOOGLE_REDIRECT_URI,
257
+ grant_type: 'authorization_code',
258
+ });
259
+ const data = await this.postForm('https://oauth2.googleapis.com/token', params);
260
+ this.saveTokens(data);
261
+ return this.tokens;
262
+ }
263
+ async getUserProfile() {
264
+ if (!this.isAuthenticated())
265
+ return null;
266
+ try {
267
+ const res = await this.googleRequest({
268
+ method: 'GET',
269
+ url: 'https://www.googleapis.com/oauth2/v2/userinfo',
270
+ });
271
+ return res.data;
272
+ }
273
+ catch (error) {
274
+ logger.error({ err: error }, 'Failed to fetch user profile');
275
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
276
+ logger.warn('Google authentication token is invalid or expired. Logging out.');
277
+ await this.logout();
278
+ }
279
+ return null;
280
+ }
281
+ }
282
+ async logout() {
283
+ if (fs.existsSync(CONFIG.TOKEN_PATH)) {
284
+ fs.unlinkSync(CONFIG.TOKEN_PATH);
285
+ }
286
+ this.tokens = {};
287
+ this.folderIdCache.clear();
288
+ }
289
+ async ensureFolder(folderName) {
290
+ if (!this.isAuthenticated())
291
+ throw new Error('Not authenticated');
292
+ const cachedId = this.folderIdCache.get(folderName);
293
+ if (cachedId)
294
+ return cachedId;
295
+ const existing = await this.findFile(folderName, undefined, 'application/vnd.google-apps.folder');
296
+ if (existing) {
297
+ this.folderIdCache.set(folderName, existing.id);
298
+ return existing.id;
299
+ }
300
+ const fileMetadata = {
301
+ name: folderName,
302
+ mimeType: 'application/vnd.google-apps.folder',
303
+ };
304
+ try {
305
+ const res = await this.googleRequest({
306
+ method: 'POST',
307
+ url: 'https://www.googleapis.com/drive/v3/files',
308
+ params: { fields: 'id' },
309
+ data: fileMetadata,
310
+ headers: { 'Content-Type': 'application/json' },
311
+ });
312
+ const id = res.data.id;
313
+ this.folderIdCache.set(folderName, id);
314
+ return id;
315
+ }
316
+ catch (error) {
317
+ logger.error({ err: error }, `Failed to create folder ${folderName}`);
318
+ throw error;
319
+ }
320
+ }
321
+ async listFiles(folderId) {
322
+ if (!this.isAuthenticated())
323
+ return [];
324
+ try {
325
+ const res = await this.googleRequest({
326
+ method: 'GET',
327
+ url: 'https://www.googleapis.com/drive/v3/files',
328
+ params: {
329
+ q: `'${folderId}' in parents and trashed = false`,
330
+ fields: 'files(id, name)',
331
+ spaces: 'drive',
332
+ },
333
+ });
334
+ return res.data.files || [];
335
+ }
336
+ catch (error) {
337
+ logger.error({ err: error }, 'Failed to list files in folder');
338
+ return [];
339
+ }
340
+ }
341
+ async deleteFile(fileId) {
342
+ if (!this.isAuthenticated())
343
+ return;
344
+ try {
345
+ await this.googleRequest({
346
+ method: 'DELETE',
347
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
348
+ });
349
+ }
350
+ catch (error) {
351
+ logger.warn({ err: error }, `Failed to delete file ${fileId}`);
352
+ }
353
+ }
354
+ async findFile(filename, parentId, mimeType) {
355
+ if (!this.isAuthenticated())
356
+ return null;
357
+ const safeName = filename.replace(/'/g, "\\'");
358
+ let query = `name = '${safeName}' and trashed = false`;
359
+ if (parentId) {
360
+ const safeParentId = parentId.replace(/'/g, "\\'");
361
+ query += ` and '${safeParentId}' in parents`;
362
+ }
363
+ if (mimeType) {
364
+ const safeMimeType = mimeType.replace(/'/g, "\\'");
365
+ query += ` and mimeType = '${safeMimeType}'`;
366
+ }
367
+ try {
368
+ const res = await this.googleRequest({
369
+ method: 'GET',
370
+ url: 'https://www.googleapis.com/drive/v3/files',
371
+ params: {
372
+ q: query,
373
+ fields: 'files(id, name)',
374
+ spaces: 'drive',
375
+ orderBy: 'createdTime desc',
376
+ },
377
+ });
378
+ if (res.data.files && res.data.files.length > 0) {
379
+ if (res.data.files.length > 1) {
380
+ logger.warn(`Multiple files found for ${filename}, using the most recent one.`);
381
+ }
382
+ return { id: res.data.files[0].id, name: res.data.files[0].name };
383
+ }
384
+ return null;
385
+ }
386
+ catch (error) {
387
+ logger.error({ err: error }, `Error while searching for file ${filename}`);
388
+ throw error;
389
+ }
390
+ }
391
+ async downloadFile(fileId, destPath) {
392
+ if (!this.isAuthenticated())
393
+ throw new Error('Not authenticated');
394
+ const dest = fs.createWriteStream(destPath);
395
+ try {
396
+ const res = await this.googleRequest({
397
+ method: 'GET',
398
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
399
+ params: { alt: 'media' },
400
+ responseType: 'stream',
401
+ });
402
+ await pipeline(res.data, dest);
403
+ }
404
+ catch (error) {
405
+ dest.destroy();
406
+ throw error;
407
+ }
408
+ }
409
+ async uploadFile(filePath, filename, mimeType = 'application/octet-stream', parentId, existingFileId) {
410
+ if (!this.isAuthenticated())
411
+ throw new Error('Not authenticated');
412
+ let targetId = existingFileId;
413
+ if (!targetId) {
414
+ const existing = await this.findFile(filename, parentId, mimeType);
415
+ if (existing)
416
+ targetId = existing.id;
417
+ }
418
+ const media = {
419
+ mimeType,
420
+ body: fs.createReadStream(filePath),
421
+ };
422
+ try {
423
+ if (targetId) {
424
+ await this.googleRequest({
425
+ method: 'PATCH',
426
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(targetId)}`,
427
+ params: { uploadType: 'media' },
428
+ data: media.body,
429
+ headers: { 'Content-Type': media.mimeType },
430
+ });
431
+ }
432
+ else {
433
+ const resource = { name: filename };
434
+ if (parentId) {
435
+ resource.parents = [parentId];
436
+ }
437
+ const created = await this.googleRequest({
438
+ method: 'POST',
439
+ url: 'https://www.googleapis.com/drive/v3/files',
440
+ params: { fields: 'id' },
441
+ data: resource,
442
+ headers: { 'Content-Type': 'application/json' },
443
+ });
444
+ await this.googleRequest({
445
+ method: 'PATCH',
446
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(created.data.id)}`,
447
+ params: { uploadType: 'media' },
448
+ data: media.body,
449
+ headers: { 'Content-Type': media.mimeType },
450
+ });
451
+ }
452
+ }
453
+ catch (error) {
454
+ logger.error({ err: error }, `Failed to upload file ${filename}`);
455
+ throw error;
456
+ }
457
+ }
458
+ getSyncFilename() {
459
+ return CONFIG.GOOGLE_SYNC_FILENAME || 'sync.json';
460
+ }
461
+ getMangaSyncFilename() {
462
+ return (CONFIG.MANGA_GOOGLE_SYNC_FILENAME ||
463
+ 'manga.sync.json');
464
+ }
465
+ getTvSyncFilename() {
466
+ return (CONFIG.TV_GOOGLE_SYNC_FILENAME || 'tv.sync.json');
467
+ }
468
+ getAsmrSyncFilename() {
469
+ return (CONFIG.ASMR_GOOGLE_SYNC_FILENAME ||
470
+ 'asmr.sync.json');
471
+ }
472
+ async findFileInAppData(filename) {
473
+ if (!this.isAuthenticated())
474
+ return null;
475
+ const safeName = filename.replace(/'/g, "\\'");
476
+ try {
477
+ const res = await this.googleRequest({
478
+ method: 'GET',
479
+ url: 'https://www.googleapis.com/drive/v3/files',
480
+ params: {
481
+ q: `name = '${safeName}' and trashed = false`,
482
+ fields: 'files(id, name)',
483
+ spaces: 'appDataFolder',
484
+ orderBy: 'createdTime desc',
485
+ },
486
+ });
487
+ if (res.data.files && res.data.files.length > 0) {
488
+ return { id: res.data.files[0].id, name: res.data.files[0].name };
489
+ }
490
+ return null;
491
+ }
492
+ catch (error) {
493
+ logger.error({ err: error }, `Error searching appDataFolder for ${filename}`);
494
+ throw error;
495
+ }
496
+ }
497
+ async downloadJson(fileId) {
498
+ const res = await this.googleRequest({
499
+ method: 'GET',
500
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
501
+ params: { alt: 'media' },
502
+ responseType: 'text',
503
+ });
504
+ return typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
505
+ }
506
+ async uploadJson(filename, payload, existingId) {
507
+ const content = JSON.stringify(payload, null, 2);
508
+ if (existingId) {
509
+ await this.googleRequest({
510
+ method: 'PATCH',
511
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(existingId)}`,
512
+ params: { uploadType: 'media' },
513
+ data: content,
514
+ headers: { 'Content-Type': 'application/json' },
515
+ });
516
+ return;
517
+ }
518
+ const created = await this.googleRequest({
519
+ method: 'POST',
520
+ url: 'https://www.googleapis.com/drive/v3/files',
521
+ params: { fields: 'id' },
522
+ data: { name: filename, parents: ['appDataFolder'] },
523
+ headers: { 'Content-Type': 'application/json' },
524
+ });
525
+ await this.googleRequest({
526
+ method: 'PATCH',
527
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(created.data.id)}`,
528
+ params: { uploadType: 'media' },
529
+ data: content,
530
+ headers: { 'Content-Type': 'application/json' },
531
+ });
532
+ }
533
+ async getRemoteVersion() {
534
+ if (!this.isAuthenticated())
535
+ return 0;
536
+ const file = await this.findFileInAppData(this.getSyncFilename());
537
+ if (!file)
538
+ return 0;
539
+ try {
540
+ const payload = (await this.downloadJson(file.id));
541
+ const row = payload?.tables?.sync_metadata?.find((r) => r.key === 'db_version');
542
+ const v = row?.value;
543
+ return typeof v === 'number' ? v : Number(v || payload?.version || 0);
544
+ }
545
+ catch {
546
+ return 0;
547
+ }
548
+ }
549
+ async getMangaRemoteVersion() {
550
+ if (!this.isAuthenticated())
551
+ return 0;
552
+ const file = await this.findFileInAppData(this.getMangaSyncFilename());
553
+ if (!file)
554
+ return 0;
555
+ try {
556
+ const payload = (await this.downloadJson(file.id));
557
+ const row = payload?.tables?.sync_metadata?.find((r) => r.key === 'db_version');
558
+ const v = row?.value;
559
+ return typeof v === 'number' ? v : Number(v || payload?.version || 0);
560
+ }
561
+ catch {
562
+ return 0;
563
+ }
564
+ }
565
+ async syncUp(db) {
566
+ if (!this.isAuthenticated())
567
+ return;
568
+ const payload = await this.exportDatabase(db);
569
+ const existing = await this.findFileInAppData(this.getSyncFilename());
570
+ await this.uploadJson(this.getSyncFilename(), payload, existing?.id);
571
+ }
572
+ async syncDown(db) {
573
+ if (!this.isAuthenticated())
574
+ return 0;
575
+ const file = await this.findFileInAppData(this.getSyncFilename());
576
+ if (!file)
577
+ return 0;
578
+ const payload = (await this.downloadJson(file.id));
579
+ this.importDatabase(db, payload);
580
+ const row = payload.tables?.sync_metadata?.find((r) => r.key === 'db_version');
581
+ return Number(row?.value || payload.version || 0);
582
+ }
583
+ async syncMangaUp(db) {
584
+ if (!this.isAuthenticated())
585
+ return;
586
+ const payload = exportTables(db, MANGA_SYNC_TABLES);
587
+ const existing = await this.findFileInAppData(this.getMangaSyncFilename());
588
+ await this.uploadJson(this.getMangaSyncFilename(), payload, existing?.id);
589
+ }
590
+ async syncMangaDown(db) {
591
+ if (!this.isAuthenticated())
592
+ return 0;
593
+ const file = await this.findFileInAppData(this.getMangaSyncFilename());
594
+ if (!file)
595
+ return 0;
596
+ const raw = await this.downloadJson(file.id);
597
+ const payload = normalizeGenericPayload(raw, MANGA_SYNC_TABLES, 'Google manga');
598
+ importTables(db, MANGA_SYNC_TABLES, payload, {
599
+ libraryTables: ['manga_library', 'manga_progress'],
600
+ backupName: 'pre-sync-manga-backup.db',
601
+ });
602
+ return readPayloadVersion(payload);
603
+ }
604
+ async getTvRemoteVersion() {
605
+ if (!this.isAuthenticated())
606
+ return 0;
607
+ const file = await this.findFileInAppData(this.getTvSyncFilename());
608
+ if (!file)
609
+ return 0;
610
+ try {
611
+ const payload = (await this.downloadJson(file.id));
612
+ const row = payload?.tables?.sync_metadata?.find((r) => r.key === 'db_version');
613
+ const v = row?.value;
614
+ return typeof v === 'number' ? v : Number(v || payload?.version || 0);
615
+ }
616
+ catch {
617
+ return 0;
618
+ }
619
+ }
620
+ async syncTvUp(db) {
621
+ if (!this.isAuthenticated())
622
+ return;
623
+ const payload = exportTables(db, TV_SYNC_TABLES);
624
+ const existing = await this.findFileInAppData(this.getTvSyncFilename());
625
+ await this.uploadJson(this.getTvSyncFilename(), payload, existing?.id);
626
+ }
627
+ async syncTvDown(db) {
628
+ if (!this.isAuthenticated())
629
+ return 0;
630
+ const file = await this.findFileInAppData(this.getTvSyncFilename());
631
+ if (!file)
632
+ return 0;
633
+ const raw = await this.downloadJson(file.id);
634
+ const payload = normalizeGenericPayload(raw, TV_SYNC_TABLES, 'Google TV');
635
+ importTables(db, TV_SYNC_TABLES, payload, {
636
+ libraryTables: ['tv_library', 'tv_progress'],
637
+ backupName: 'pre-sync-tv-backup.db',
638
+ });
639
+ return readPayloadVersion(payload);
640
+ }
641
+ async getAsmrRemoteVersion() {
642
+ if (!this.isAuthenticated())
643
+ return 0;
644
+ const file = await this.findFileInAppData(this.getAsmrSyncFilename());
645
+ if (!file)
646
+ return 0;
647
+ try {
648
+ const payload = (await this.downloadJson(file.id));
649
+ const row = payload?.tables?.sync_metadata?.find((r) => r.key === 'db_version');
650
+ const v = row?.value;
651
+ return typeof v === 'number' ? v : Number(v || payload?.version || 0);
652
+ }
653
+ catch {
654
+ return 0;
655
+ }
656
+ }
657
+ async syncAsmrUp(db) {
658
+ if (!this.isAuthenticated())
659
+ return;
660
+ const payload = exportTables(db, ASMR_SYNC_TABLES);
661
+ const existing = await this.findFileInAppData(this.getAsmrSyncFilename());
662
+ await this.uploadJson(this.getAsmrSyncFilename(), payload, existing?.id);
663
+ }
664
+ async syncAsmrDown(db) {
665
+ if (!this.isAuthenticated())
666
+ return 0;
667
+ const file = await this.findFileInAppData(this.getAsmrSyncFilename());
668
+ if (!file)
669
+ return 0;
670
+ const raw = await this.downloadJson(file.id);
671
+ const payload = normalizeGenericPayload(raw, ASMR_SYNC_TABLES, 'Google ASMR');
672
+ importTables(db, ASMR_SYNC_TABLES, payload, {
673
+ libraryTables: ['asmr_library', 'asmr_progress'],
674
+ backupName: 'pre-sync-asmr-backup.db',
675
+ });
676
+ return readPayloadVersion(payload);
677
+ }
678
+ async exportDatabase(db) {
679
+ const tables = [
680
+ 'watchlist',
681
+ 'watched_episodes',
682
+ 'queue',
683
+ 'settings',
684
+ 'shows_meta',
685
+ 'sync_metadata',
686
+ 'dismissed_notifications',
687
+ 'discovered_notifications',
688
+ ];
689
+ const out = {};
690
+ for (const table of tables) {
691
+ out[table] = dbAll(db, `SELECT * FROM "${table.replace(/"/g, '""')}"`).filter((row) => !isTempSyncRow(row));
692
+ }
693
+ const versionRow = out.sync_metadata.find((r) => r.key === 'db_version');
694
+ return {
695
+ version: Number(versionRow?.value || 0),
696
+ exportedAt: new Date().toISOString(),
697
+ tables: out,
698
+ };
699
+ }
700
+ importDatabase(db, payload) {
701
+ const remoteLibraryRows = (payload.tables.watchlist?.length ?? 0) + (payload.tables.watched_episodes?.length ?? 0);
702
+ const localLibraryRows = dbGet(db, 'SELECT (SELECT COUNT(*) FROM watchlist) + (SELECT COUNT(*) FROM watched_episodes) AS n')?.n ?? 0;
703
+ if (remoteLibraryRows === 0 && localLibraryRows > 0) {
704
+ throw new Error('Sync down refused: remote library is empty while local library has data');
705
+ }
706
+ try {
707
+ db.backup(path.join(CONFIG.ROOT, 'pre-sync-backup.db'));
708
+ }
709
+ catch (err) {
710
+ logger.warn({ err }, 'Pre-sync backup failed, continuing without backup');
711
+ }
712
+ const tables = [
713
+ 'watchlist',
714
+ 'watched_episodes',
715
+ 'queue',
716
+ 'settings',
717
+ 'shows_meta',
718
+ 'sync_metadata',
719
+ 'dismissed_notifications',
720
+ 'discovered_notifications',
721
+ ];
722
+ const localColumns = new Map();
723
+ for (const table of tables) {
724
+ const cols = db.all(`PRAGMA table_info("${table}")`);
725
+ localColumns.set(table, new Set(cols.map((c) => c.name)));
726
+ }
727
+ db.serialize(() => {
728
+ for (const table of tables) {
729
+ db.run(`DELETE FROM "${table}"`);
730
+ }
731
+ for (const table of tables) {
732
+ const known = localColumns.get(table);
733
+ for (const row of payload.tables[table] || []) {
734
+ if (isTempSyncRow(row))
735
+ continue;
736
+ const columns = Object.keys(row).filter((c) => known?.has(c));
737
+ if (columns.length === 0)
738
+ continue;
739
+ const columnSql = columns.map((c) => `"${c.replace(/"/g, '""')}"`).join(', ');
740
+ const placeholders = columns.map(() => '?').join(', ');
741
+ const values = columns.map((c) => row[c]);
742
+ db.run(`INSERT INTO "${table}" (${columnSql}) VALUES (${placeholders})`, values);
743
+ }
744
+ }
745
+ });
746
+ }
747
+ }
748
+ export const googleDriveService = new GoogleDriveService();