@serifpersia/dango 3.0.3

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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/client/dist/assets/AnimeInfo-CMxs0nRH.css +1 -0
  4. package/client/dist/assets/AnimeInfo-DVdaocCl.js +1 -0
  5. package/client/dist/assets/AnimeInfoPage-Bx5EvGt-.js +2 -0
  6. package/client/dist/assets/Asmr-C2i9GKD4.css +1 -0
  7. package/client/dist/assets/Asmr-CIjmFtRH.js +2 -0
  8. package/client/dist/assets/Asmr.module-DPG2JhDJ.js +1 -0
  9. package/client/dist/assets/AvSyncCalibrator-CIYcyon5.css +1 -0
  10. package/client/dist/assets/AvSyncCalibrator-vqErH7Cm.js +10 -0
  11. package/client/dist/assets/ErrorMessage-BwSjzblw.js +1 -0
  12. package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
  13. package/client/dist/assets/Home-D7YfGBUk.js +1 -0
  14. package/client/dist/assets/Home-DKCfEiwl.css +1 -0
  15. package/client/dist/assets/Insights-BljI64Ua.css +1 -0
  16. package/client/dist/assets/Insights-CLQGrlpX.js +1 -0
  17. package/client/dist/assets/Manga-DQ9HYnpk.js +1 -0
  18. package/client/dist/assets/Manga-H8Wb04w3.css +1 -0
  19. package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
  20. package/client/dist/assets/Map-L_ryVBTq.js +1 -0
  21. package/client/dist/assets/Mature-BeWzAJ7B.js +1 -0
  22. package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
  23. package/client/dist/assets/Player-CYpd__FP.js +30 -0
  24. package/client/dist/assets/Player-DreAhybc.css +1 -0
  25. package/client/dist/assets/PlayerRedirect-fxUAp9JV.js +1 -0
  26. package/client/dist/assets/PlayerSettings-BcsotxP9.js +1 -0
  27. package/client/dist/assets/PlayerSettings-Cqua1CDD.css +1 -0
  28. package/client/dist/assets/QueueRail-B9Pgbqc3.js +1 -0
  29. package/client/dist/assets/QueueRail-BnaltOP8.css +1 -0
  30. package/client/dist/assets/Radio-BvdY5twD.js +1 -0
  31. package/client/dist/assets/Radio-CUuYy3DY.css +1 -0
  32. package/client/dist/assets/Search-BzO-aRP7.css +1 -0
  33. package/client/dist/assets/Search-R9YLAXS1.js +1 -0
  34. package/client/dist/assets/Search.module-D8ZbQAiK.js +1 -0
  35. package/client/dist/assets/Settings-9kYcXdTR.js +21 -0
  36. package/client/dist/assets/Settings-DF8JThUL.css +1 -0
  37. package/client/dist/assets/SkeletonGrid-D5vRvX5q.js +1 -0
  38. package/client/dist/assets/SkeletonGrid-DEbYtO4H.css +1 -0
  39. package/client/dist/assets/SynopsisText-BlFw0R-0.js +1 -0
  40. package/client/dist/assets/SynopsisText-CUgOjOxt.css +1 -0
  41. package/client/dist/assets/Trackers-DfZlnnVq.js +2 -0
  42. package/client/dist/assets/Trackers-Dt9lR_uP.css +1 -0
  43. package/client/dist/assets/Tv-CVKC2YBs.css +1 -0
  44. package/client/dist/assets/Tv-Db1Wx_b7.js +15 -0
  45. package/client/dist/assets/Watchlist-B2nkNZqM.css +1 -0
  46. package/client/dist/assets/Watchlist-a77CYtzu.js +1 -0
  47. package/client/dist/assets/hls-6Owgw2Aj.js +1 -0
  48. package/client/dist/assets/hls-BOLXTnjL.js +42 -0
  49. package/client/dist/assets/index-B6qdXFtl.css +1 -0
  50. package/client/dist/assets/index-B8TPVaIS.js +30354 -0
  51. package/client/dist/assets/index-CpjgU8ou.js +178 -0
  52. package/client/dist/assets/useAnimeInfoData-BOfyi9UJ.js +1 -0
  53. package/client/dist/assets/useIsMobile-CM15uQn8.css +1 -0
  54. package/client/dist/assets/useIsMobile-Cb8HcKRj.js +1 -0
  55. package/client/dist/assets/useMatureConsent-CZMYBVXd.js +1 -0
  56. package/client/dist/assets/vendor-rLNfZm7Y.js +1 -0
  57. package/client/dist/av-sync-test.mp4 +0 -0
  58. package/client/dist/favicon.ico +0 -0
  59. package/client/dist/index.html +31 -0
  60. package/client/dist/logo-mochi.png +0 -0
  61. package/client/dist/logo-sakura.png +0 -0
  62. package/client/dist/logo.png +0 -0
  63. package/client/dist/placeholder.svg +4 -0
  64. package/client/dist/robots.txt +3 -0
  65. package/orchestrator.js +416 -0
  66. package/package.json +89 -0
  67. package/server/dist/app-auth.js +231 -0
  68. package/server/dist/config.js +133 -0
  69. package/server/dist/controllers/auth.controller.js +247 -0
  70. package/server/dist/controllers/data.controller.js +785 -0
  71. package/server/dist/controllers/insights.controller.js +197 -0
  72. package/server/dist/controllers/proxy.controller.js +665 -0
  73. package/server/dist/controllers/settings.controller.js +267 -0
  74. package/server/dist/controllers/watchlist.controller.js +967 -0
  75. package/server/dist/db.js +152 -0
  76. package/server/dist/discord-gateway.js +381 -0
  77. package/server/dist/discord-rpc.js +424 -0
  78. package/server/dist/github-sync.js +403 -0
  79. package/server/dist/google.js +549 -0
  80. package/server/dist/lib/anilist.js +1519 -0
  81. package/server/dist/lib/anischedule.js +202 -0
  82. package/server/dist/lib/ipc.js +32 -0
  83. package/server/dist/lib/kitsu.js +552 -0
  84. package/server/dist/lib/mal.js +886 -0
  85. package/server/dist/lib/migration.js +335 -0
  86. package/server/dist/lib/temp-ids.js +11 -0
  87. package/server/dist/lib/tmdb.js +71 -0
  88. package/server/dist/lib/tracker/anilist-tracker.js +250 -0
  89. package/server/dist/lib/tracker/sync.service.js +312 -0
  90. package/server/dist/logger.js +19 -0
  91. package/server/dist/providers/123anime.provider.js +240 -0
  92. package/server/dist/providers/anilight.provider.js +366 -0
  93. package/server/dist/providers/animepahe.provider.js +531 -0
  94. package/server/dist/providers/animeya.provider.js +746 -0
  95. package/server/dist/providers/base-provider.js +45 -0
  96. package/server/dist/providers/hn.provider.js +473 -0
  97. package/server/dist/providers/ht.provider.js +439 -0
  98. package/server/dist/providers/jasmr.provider.js +504 -0
  99. package/server/dist/providers/kaa.provider.js +388 -0
  100. package/server/dist/providers/manga/manga.types.js +10 -0
  101. package/server/dist/providers/manga/mangadex.provider.js +227 -0
  102. package/server/dist/providers/manga/mangapill.provider.js +232 -0
  103. package/server/dist/providers/megaplay.provider.js +583 -0
  104. package/server/dist/providers/op.provider.js +440 -0
  105. package/server/dist/providers/provider.interface.js +2 -0
  106. package/server/dist/providers/title-matching.js +234 -0
  107. package/server/dist/providers/wh.provider.js +600 -0
  108. package/server/dist/rclone.js +126 -0
  109. package/server/dist/repositories/insights.repository.js +43 -0
  110. package/server/dist/repositories/mal-cache.repository.js +66 -0
  111. package/server/dist/repositories/notifications.repository.js +40 -0
  112. package/server/dist/repositories/queue.repository.js +64 -0
  113. package/server/dist/repositories/settings.repository.js +15 -0
  114. package/server/dist/repositories/shows-meta.repository.js +43 -0
  115. package/server/dist/repositories/temp-show-ids.repository.js +54 -0
  116. package/server/dist/repositories/watched-episodes.repository.js +54 -0
  117. package/server/dist/repositories/watchlist.repository.js +74 -0
  118. package/server/dist/routes/asmr.routes.js +72 -0
  119. package/server/dist/routes/auth.routes.js +28 -0
  120. package/server/dist/routes/data.routes.js +50 -0
  121. package/server/dist/routes/discord-gateway.routes.js +57 -0
  122. package/server/dist/routes/insights.routes.js +12 -0
  123. package/server/dist/routes/lan-auth.routes.js +49 -0
  124. package/server/dist/routes/manga.routes.js +115 -0
  125. package/server/dist/routes/proxy.routes.js +15 -0
  126. package/server/dist/routes/radio.routes.js +143 -0
  127. package/server/dist/routes/settings.routes.js +27 -0
  128. package/server/dist/routes/tracker.routes.js +143 -0
  129. package/server/dist/routes/translate.routes.js +90 -0
  130. package/server/dist/routes/tv.routes.js +843 -0
  131. package/server/dist/routes/watchlist.routes.js +312 -0
  132. package/server/dist/server.js +310 -0
  133. package/server/dist/shipped-defaults.js +8 -0
  134. package/server/dist/sync-config.js +32 -0
  135. package/server/dist/sync.js +506 -0
  136. package/server/dist/utils/cookie.utils.js +17 -0
  137. package/server/dist/utils/db-utils.js +15 -0
  138. package/server/dist/utils/env.utils.js +83 -0
  139. package/server/dist/utils/machine-id.js +46 -0
  140. package/server/dist/utils/request-context.js +5 -0
  141. package/server/dist/utils/security.utils.js +99 -0
@@ -0,0 +1,549 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.googleDriveService = exports.GoogleDriveService = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const http_1 = __importDefault(require("http"));
9
+ const https_1 = __importDefault(require("https"));
10
+ const axios_1 = __importDefault(require("axios"));
11
+ const promises_1 = require("stream/promises");
12
+ const logger_1 = __importDefault(require("./logger"));
13
+ const config_1 = require("./config");
14
+ const db_utils_1 = require("./utils/db-utils");
15
+ const temp_ids_1 = require("./lib/temp-ids");
16
+ const httpAgent = new http_1.default.Agent({ keepAlive: false });
17
+ const httpsAgent = new https_1.default.Agent({ keepAlive: false });
18
+ httpsAgent.setMaxListeners(100);
19
+ httpAgent.setMaxListeners(100);
20
+ const googleAxios = axios_1.default.create({
21
+ httpAgent,
22
+ httpsAgent,
23
+ timeout: 30000,
24
+ });
25
+ class GoogleDriveService {
26
+ tokens = {};
27
+ folderIdCache = new Map();
28
+ constructor() {
29
+ // Client ID/secret now live in Cloudflare Worker (GOOGLE_AUTH_WORKER_URL).
30
+ // Legacy user-owned .env credentials are optional fallback only.
31
+ if (!config_1.CONFIG.GOOGLE_AUTH_WORKER_URL && !config_1.CONFIG.GOOGLE_CLIENT_ID) {
32
+ logger_1.default.error('GOOGLE_AUTH_WORKER_URL is missing and no GOOGLE_CLIENT_ID fallback!');
33
+ }
34
+ this.loadTokens();
35
+ }
36
+ useWorker() {
37
+ return !!config_1.CONFIG.GOOGLE_AUTH_WORKER_URL;
38
+ }
39
+ loadTokens() {
40
+ if (fs_1.default.existsSync(config_1.CONFIG.TOKEN_PATH)) {
41
+ try {
42
+ this.tokens = JSON.parse(fs_1.default.readFileSync(config_1.CONFIG.TOKEN_PATH, 'utf-8'));
43
+ }
44
+ catch (error) {
45
+ logger_1.default.error({ err: error }, 'Failed to load Google tokens');
46
+ }
47
+ }
48
+ }
49
+ saveTokens(tokens) {
50
+ const merged = { ...this.tokens, ...tokens };
51
+ if (merged.expires_in && !merged.expiry_date) {
52
+ merged.expiry_date = Date.now() + merged.expires_in * 1000;
53
+ }
54
+ this.tokens = merged;
55
+ try {
56
+ fs_1.default.writeFileSync(config_1.CONFIG.TOKEN_PATH, JSON.stringify(this.tokens));
57
+ }
58
+ catch (error) {
59
+ logger_1.default.error({ err: error }, 'Failed to save refreshed tokens');
60
+ }
61
+ }
62
+ getGoogleClientConfig() {
63
+ // Legacy fallback only. Preferred path is Worker (no secret in dango).
64
+ return {
65
+ clientId: config_1.CONFIG.GOOGLE_CLIENT_ID || '',
66
+ clientSecret: config_1.CONFIG.GOOGLE_CLIENT_SECRET || '',
67
+ };
68
+ }
69
+ async refreshViaWorker() {
70
+ if (!this.tokens.refresh_token)
71
+ return false;
72
+ try {
73
+ const { data } = await googleAxios.post(`${config_1.CONFIG.GOOGLE_AUTH_WORKER_URL}/refresh`, { refresh_token: this.tokens.refresh_token }, { headers: { 'Content-Type': 'application/json' } });
74
+ this.saveTokens(data);
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ if (axios_1.default.isAxiosError(error) &&
79
+ (error.response?.status === 400 || error.response?.status === 401)) {
80
+ logger_1.default.warn('Worker refresh failed. Token may be revoked. Logging out.');
81
+ await this.logout();
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+ async refreshAccessToken() {
87
+ if (!this.tokens.refresh_token) {
88
+ throw new Error('Missing refresh token');
89
+ }
90
+ // Preferred: secret stays in Worker
91
+ if (this.useWorker()) {
92
+ await this.refreshViaWorker();
93
+ return;
94
+ }
95
+ const { clientId, clientSecret } = this.getGoogleClientConfig();
96
+ if (!clientId || !clientSecret) {
97
+ throw new Error('Google OAuth credentials are not configured');
98
+ }
99
+ const params = new URLSearchParams({
100
+ client_id: clientId,
101
+ client_secret: clientSecret,
102
+ refresh_token: this.tokens.refresh_token,
103
+ grant_type: 'refresh_token',
104
+ });
105
+ try {
106
+ const { data } = await googleAxios.post('https://oauth2.googleapis.com/token', params.toString(), {
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ });
109
+ this.saveTokens(data);
110
+ }
111
+ catch (error) {
112
+ if (axios_1.default.isAxiosError(error) &&
113
+ (error.response?.status === 400 || error.response?.status === 401)) {
114
+ logger_1.default.warn('Failed to refresh Google access token. Token may be revoked. Logging out.');
115
+ await this.logout();
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+ async ensureAccessToken() {
121
+ const expiresSoon = !this.tokens.expiry_date || Date.now() >= this.tokens.expiry_date - 60_000;
122
+ if (!this.tokens.access_token || expiresSoon) {
123
+ await this.refreshAccessToken();
124
+ }
125
+ }
126
+ async googleRequest(config) {
127
+ await this.ensureAccessToken();
128
+ try {
129
+ return await googleAxios.request({
130
+ ...config,
131
+ headers: {
132
+ Authorization: `Bearer ${this.tokens.access_token}`,
133
+ ...(config.headers ?? {}),
134
+ },
135
+ });
136
+ }
137
+ catch (error) {
138
+ if (axios_1.default.isAxiosError(error) &&
139
+ (error.response?.status === 401 || error.response?.status === 403)) {
140
+ logger_1.default.warn('Google API request failed with auth error. Logging out.');
141
+ await this.logout();
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+ isAuthenticated() {
147
+ return !!this.tokens.refresh_token;
148
+ }
149
+ async getAuthUrl() {
150
+ // Preferred: Worker builds URL with bundled client_id, no secret needed here
151
+ if (this.useWorker()) {
152
+ try {
153
+ const { data } = await googleAxios.get(`${config_1.CONFIG.GOOGLE_AUTH_WORKER_URL}/auth-url`, { params: { redirect_uri: config_1.CONFIG.GOOGLE_REDIRECT_URI } });
154
+ if (data?.url)
155
+ return data.url;
156
+ }
157
+ catch (error) {
158
+ logger_1.default.error({ err: error }, 'Worker /auth-url failed, falling back to local');
159
+ }
160
+ }
161
+ const { clientId } = this.getGoogleClientConfig();
162
+ if (!clientId)
163
+ throw new Error('Google OAuth credentials are not configured');
164
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
165
+ url.searchParams.set('client_id', clientId);
166
+ url.searchParams.set('redirect_uri', config_1.CONFIG.GOOGLE_REDIRECT_URI);
167
+ url.searchParams.set('response_type', 'code');
168
+ url.searchParams.set('access_type', 'offline');
169
+ url.searchParams.set('prompt', 'consent');
170
+ url.searchParams.set('scope', config_1.CONFIG.GOOGLE_SCOPES.join(' '));
171
+ return url.toString();
172
+ }
173
+ async handleCallback(code) {
174
+ // Preferred: Worker exchanges code with secret, dango never sees secret
175
+ if (this.useWorker()) {
176
+ try {
177
+ const { data } = await googleAxios.post(`${config_1.CONFIG.GOOGLE_AUTH_WORKER_URL}/exchange`, { code, redirect_uri: config_1.CONFIG.GOOGLE_REDIRECT_URI }, { headers: { 'Content-Type': 'application/json' } });
178
+ this.saveTokens(data);
179
+ return this.tokens;
180
+ }
181
+ catch (error) {
182
+ logger_1.default.error({ err: error }, 'Worker /exchange failed');
183
+ throw error;
184
+ }
185
+ }
186
+ // Legacy fallback: user-owned client_id/secret in .env
187
+ const { clientId, clientSecret } = this.getGoogleClientConfig();
188
+ const params = new URLSearchParams({
189
+ code,
190
+ client_id: clientId,
191
+ client_secret: clientSecret,
192
+ redirect_uri: config_1.CONFIG.GOOGLE_REDIRECT_URI,
193
+ grant_type: 'authorization_code',
194
+ });
195
+ const { data } = await googleAxios.post('https://oauth2.googleapis.com/token', params.toString(), {
196
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
197
+ });
198
+ this.saveTokens(data);
199
+ return this.tokens;
200
+ }
201
+ async getUserProfile() {
202
+ if (!this.isAuthenticated())
203
+ return null;
204
+ try {
205
+ const res = await this.googleRequest({
206
+ method: 'GET',
207
+ url: 'https://www.googleapis.com/oauth2/v2/userinfo',
208
+ });
209
+ return res.data;
210
+ }
211
+ catch (error) {
212
+ logger_1.default.error({ err: error }, 'Failed to fetch user profile');
213
+ if (axios_1.default.isAxiosError(error) &&
214
+ (error.response?.status === 401 || error.response?.status === 403)) {
215
+ logger_1.default.warn('Google authentication token is invalid or expired. Logging out.');
216
+ await this.logout();
217
+ }
218
+ return null;
219
+ }
220
+ }
221
+ async logout() {
222
+ if (fs_1.default.existsSync(config_1.CONFIG.TOKEN_PATH)) {
223
+ fs_1.default.unlinkSync(config_1.CONFIG.TOKEN_PATH);
224
+ }
225
+ this.tokens = {};
226
+ this.folderIdCache.clear();
227
+ }
228
+ async ensureFolder(folderName) {
229
+ if (!this.isAuthenticated())
230
+ throw new Error('Not authenticated');
231
+ const cachedId = this.folderIdCache.get(folderName);
232
+ if (cachedId)
233
+ return cachedId;
234
+ const existing = await this.findFile(folderName, undefined, 'application/vnd.google-apps.folder');
235
+ if (existing) {
236
+ this.folderIdCache.set(folderName, existing.id);
237
+ return existing.id;
238
+ }
239
+ const fileMetadata = {
240
+ name: folderName,
241
+ mimeType: 'application/vnd.google-apps.folder',
242
+ };
243
+ try {
244
+ const res = await this.googleRequest({
245
+ method: 'POST',
246
+ url: 'https://www.googleapis.com/drive/v3/files',
247
+ params: { fields: 'id' },
248
+ data: fileMetadata,
249
+ headers: { 'Content-Type': 'application/json' },
250
+ });
251
+ const id = res.data.id;
252
+ this.folderIdCache.set(folderName, id);
253
+ return id;
254
+ }
255
+ catch (error) {
256
+ logger_1.default.error({ err: error }, `Failed to create folder ${folderName}`);
257
+ throw error;
258
+ }
259
+ }
260
+ async listFiles(folderId) {
261
+ if (!this.isAuthenticated())
262
+ return [];
263
+ try {
264
+ const res = await this.googleRequest({
265
+ method: 'GET',
266
+ url: 'https://www.googleapis.com/drive/v3/files',
267
+ params: {
268
+ q: `'${folderId}' in parents and trashed = false`,
269
+ fields: 'files(id, name)',
270
+ spaces: 'drive',
271
+ },
272
+ });
273
+ return res.data.files || [];
274
+ }
275
+ catch (error) {
276
+ logger_1.default.error({ err: error }, 'Failed to list files in folder');
277
+ return [];
278
+ }
279
+ }
280
+ async deleteFile(fileId) {
281
+ if (!this.isAuthenticated())
282
+ return;
283
+ try {
284
+ await this.googleRequest({
285
+ method: 'DELETE',
286
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
287
+ });
288
+ }
289
+ catch (error) {
290
+ logger_1.default.warn({ err: error }, `Failed to delete file ${fileId}`);
291
+ }
292
+ }
293
+ async findFile(filename, parentId, mimeType) {
294
+ if (!this.isAuthenticated())
295
+ return null;
296
+ const safeName = filename.replace(/'/g, "\\'");
297
+ let query = `name = '${safeName}' and trashed = false`;
298
+ if (parentId) {
299
+ const safeParentId = parentId.replace(/'/g, "\\'");
300
+ query += ` and '${safeParentId}' in parents`;
301
+ }
302
+ if (mimeType) {
303
+ const safeMimeType = mimeType.replace(/'/g, "\\'");
304
+ query += ` and mimeType = '${safeMimeType}'`;
305
+ }
306
+ try {
307
+ const res = await this.googleRequest({
308
+ method: 'GET',
309
+ url: 'https://www.googleapis.com/drive/v3/files',
310
+ params: {
311
+ q: query,
312
+ fields: 'files(id, name)',
313
+ spaces: 'drive',
314
+ orderBy: 'createdTime desc',
315
+ },
316
+ });
317
+ if (res.data.files && res.data.files.length > 0) {
318
+ if (res.data.files.length > 1) {
319
+ logger_1.default.warn(`Multiple files found for ${filename}, using the most recent one.`);
320
+ }
321
+ return { id: res.data.files[0].id, name: res.data.files[0].name };
322
+ }
323
+ return null;
324
+ }
325
+ catch (error) {
326
+ logger_1.default.error({ err: error }, `Error while searching for file ${filename}`);
327
+ throw error;
328
+ }
329
+ }
330
+ async downloadFile(fileId, destPath) {
331
+ if (!this.isAuthenticated())
332
+ throw new Error('Not authenticated');
333
+ const dest = fs_1.default.createWriteStream(destPath);
334
+ try {
335
+ const res = await this.googleRequest({
336
+ method: 'GET',
337
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
338
+ params: { alt: 'media' },
339
+ responseType: 'stream',
340
+ });
341
+ await (0, promises_1.pipeline)(res.data, dest);
342
+ }
343
+ catch (error) {
344
+ dest.destroy();
345
+ throw error;
346
+ }
347
+ }
348
+ async uploadFile(filePath, filename, mimeType = 'application/octet-stream', parentId, existingFileId) {
349
+ if (!this.isAuthenticated())
350
+ throw new Error('Not authenticated');
351
+ let targetId = existingFileId;
352
+ if (!targetId) {
353
+ const existing = await this.findFile(filename, parentId, mimeType);
354
+ if (existing)
355
+ targetId = existing.id;
356
+ }
357
+ const media = {
358
+ mimeType,
359
+ body: fs_1.default.createReadStream(filePath),
360
+ };
361
+ try {
362
+ if (targetId) {
363
+ await this.googleRequest({
364
+ method: 'PATCH',
365
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(targetId)}`,
366
+ params: { uploadType: 'media' },
367
+ data: media.body,
368
+ headers: { 'Content-Type': media.mimeType },
369
+ });
370
+ }
371
+ else {
372
+ const resource = { name: filename };
373
+ if (parentId) {
374
+ resource.parents = [parentId];
375
+ }
376
+ const created = await this.googleRequest({
377
+ method: 'POST',
378
+ url: 'https://www.googleapis.com/drive/v3/files',
379
+ params: { fields: 'id' },
380
+ data: resource,
381
+ headers: { 'Content-Type': 'application/json' },
382
+ });
383
+ await this.googleRequest({
384
+ method: 'PATCH',
385
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(created.data.id)}`,
386
+ params: { uploadType: 'media' },
387
+ data: media.body,
388
+ headers: { 'Content-Type': media.mimeType },
389
+ });
390
+ }
391
+ }
392
+ catch (error) {
393
+ logger_1.default.error({ err: error }, `Failed to upload file ${filename}`);
394
+ throw error;
395
+ }
396
+ }
397
+ getSyncFilename() {
398
+ return config_1.CONFIG.GOOGLE_SYNC_FILENAME || 'sync.json';
399
+ }
400
+ async findFileInAppData(filename) {
401
+ if (!this.isAuthenticated())
402
+ return null;
403
+ const safeName = filename.replace(/'/g, "\\'");
404
+ try {
405
+ const res = await this.googleRequest({
406
+ method: 'GET',
407
+ url: 'https://www.googleapis.com/drive/v3/files',
408
+ params: {
409
+ q: `name = '${safeName}' and trashed = false`,
410
+ fields: 'files(id, name)',
411
+ spaces: 'appDataFolder',
412
+ orderBy: 'createdTime desc',
413
+ },
414
+ });
415
+ if (res.data.files && res.data.files.length > 0) {
416
+ return { id: res.data.files[0].id, name: res.data.files[0].name };
417
+ }
418
+ return null;
419
+ }
420
+ catch (error) {
421
+ logger_1.default.error({ err: error }, `Error searching appDataFolder for ${filename}`);
422
+ throw error;
423
+ }
424
+ }
425
+ async downloadJson(fileId) {
426
+ const res = await this.googleRequest({
427
+ method: 'GET',
428
+ url: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
429
+ params: { alt: 'media' },
430
+ responseType: 'text',
431
+ });
432
+ return typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
433
+ }
434
+ async uploadJson(filename, payload, existingId) {
435
+ const content = JSON.stringify(payload, null, 2);
436
+ if (existingId) {
437
+ await this.googleRequest({
438
+ method: 'PATCH',
439
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(existingId)}`,
440
+ params: { uploadType: 'media' },
441
+ data: content,
442
+ headers: { 'Content-Type': 'application/json' },
443
+ });
444
+ return;
445
+ }
446
+ const created = await this.googleRequest({
447
+ method: 'POST',
448
+ url: 'https://www.googleapis.com/drive/v3/files',
449
+ params: { fields: 'id' },
450
+ data: { name: filename, parents: ['appDataFolder'] },
451
+ headers: { 'Content-Type': 'application/json' },
452
+ });
453
+ await this.googleRequest({
454
+ method: 'PATCH',
455
+ url: `https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(created.data.id)}`,
456
+ params: { uploadType: 'media' },
457
+ data: content,
458
+ headers: { 'Content-Type': 'application/json' },
459
+ });
460
+ }
461
+ async getRemoteVersion() {
462
+ if (!this.isAuthenticated())
463
+ return 0;
464
+ const file = await this.findFileInAppData(this.getSyncFilename());
465
+ if (!file)
466
+ return 0;
467
+ try {
468
+ const payload = (await this.downloadJson(file.id));
469
+ const row = payload?.tables?.sync_metadata?.find((r) => r.key === 'db_version');
470
+ const v = row?.value;
471
+ return typeof v === 'number' ? v : Number(v || payload?.version || 0);
472
+ }
473
+ catch {
474
+ return 0;
475
+ }
476
+ }
477
+ async syncUp(db) {
478
+ if (!this.isAuthenticated())
479
+ return;
480
+ const payload = await this.exportDatabase(db);
481
+ const existing = await this.findFileInAppData(this.getSyncFilename());
482
+ await this.uploadJson(this.getSyncFilename(), payload, existing?.id);
483
+ }
484
+ async syncDown(db) {
485
+ if (!this.isAuthenticated())
486
+ return 0;
487
+ const file = await this.findFileInAppData(this.getSyncFilename());
488
+ if (!file)
489
+ return 0;
490
+ const payload = (await this.downloadJson(file.id));
491
+ this.importDatabase(db, payload);
492
+ const row = payload.tables?.sync_metadata?.find((r) => r.key === 'db_version');
493
+ return Number(row?.value || payload.version || 0);
494
+ }
495
+ async exportDatabase(db) {
496
+ const tables = [
497
+ 'watchlist',
498
+ 'watched_episodes',
499
+ 'queue',
500
+ 'settings',
501
+ 'shows_meta',
502
+ 'sync_metadata',
503
+ 'dismissed_notifications',
504
+ 'discovered_notifications',
505
+ ];
506
+ const out = {};
507
+ for (const table of tables) {
508
+ out[table] = (0, db_utils_1.dbAll)(db, `SELECT * FROM "${table.replace(/"/g, '""')}"`).filter((row) => !(0, temp_ids_1.isTempSyncRow)(row));
509
+ }
510
+ const versionRow = out.sync_metadata.find((r) => r.key === 'db_version');
511
+ return {
512
+ version: Number(versionRow?.value || 0),
513
+ exportedAt: new Date().toISOString(),
514
+ tables: out,
515
+ };
516
+ }
517
+ importDatabase(db, payload) {
518
+ const tables = [
519
+ 'watchlist',
520
+ 'watched_episodes',
521
+ 'queue',
522
+ 'settings',
523
+ 'shows_meta',
524
+ 'sync_metadata',
525
+ 'dismissed_notifications',
526
+ 'discovered_notifications',
527
+ ];
528
+ db.serialize(() => {
529
+ for (const table of tables) {
530
+ db.run(`DELETE FROM "${table}"`);
531
+ }
532
+ for (const table of tables) {
533
+ for (const row of payload.tables[table] || []) {
534
+ if ((0, temp_ids_1.isTempSyncRow)(row))
535
+ continue;
536
+ const columns = Object.keys(row);
537
+ if (columns.length === 0)
538
+ continue;
539
+ const columnSql = columns.map((c) => `"${c.replace(/"/g, '""')}"`).join(', ');
540
+ const placeholders = columns.map(() => '?').join(', ');
541
+ const values = columns.map((c) => row[c]);
542
+ db.run(`INSERT INTO "${table}" (${columnSql}) VALUES (${placeholders})`, values);
543
+ }
544
+ }
545
+ });
546
+ }
547
+ }
548
+ exports.GoogleDriveService = GoogleDriveService;
549
+ exports.googleDriveService = new GoogleDriveService();