@akilles/soundcloud-watcher 2.1.0 → 2.2.1
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.
- package/package.json +4 -1
- package/soundcloud_watcher.ts +91 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akilles/soundcloud-watcher",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"openclaw": {
|
|
@@ -44,5 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=22.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^5.9.3"
|
|
47
50
|
}
|
|
48
51
|
}
|
package/soundcloud_watcher.ts
CHANGED
|
@@ -25,6 +25,7 @@ const CONFIG_FILE = path.join(OPENCLAW_DIR, "secrets", "soundcloud.env");
|
|
|
25
25
|
const ACCOUNT_DATA = path.join(OPENCLAW_DIR, "data", "soundcloud_tracking.json");
|
|
26
26
|
const ARTISTS_DATA = path.join(OPENCLAW_DIR, "data", "artists.json");
|
|
27
27
|
const BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_backoff.json");
|
|
28
|
+
const TOKEN_BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_token_backoff.json");
|
|
28
29
|
|
|
29
30
|
// =============================================================================
|
|
30
31
|
// TUNING
|
|
@@ -40,6 +41,13 @@ const FOLLOWERS_PAGE_SIZE = 200;
|
|
|
40
41
|
const BACKOFF_BASE_SECONDS = 300;
|
|
41
42
|
const BACKOFF_MAX_SECONDS = 7200;
|
|
42
43
|
|
|
44
|
+
// Token refresh has lower backoff - it's critical for operation
|
|
45
|
+
const TOKEN_BACKOFF_BASE_SECONDS = 60;
|
|
46
|
+
const TOKEN_BACKOFF_MAX_SECONDS = 300; // 5 min max, not 2 hours
|
|
47
|
+
|
|
48
|
+
// Proactive refresh buffer (refresh 5 min before expiry)
|
|
49
|
+
const TOKEN_REFRESH_BUFFER_SECONDS = 300;
|
|
50
|
+
|
|
43
51
|
const API_TIMEOUT_MS = 15_000;
|
|
44
52
|
|
|
45
53
|
// =============================================================================
|
|
@@ -227,6 +235,7 @@ class SoundCloudAPI {
|
|
|
227
235
|
private log: (...args: any[]) => void
|
|
228
236
|
) {}
|
|
229
237
|
|
|
238
|
+
// -- API rate limit backoff (for 429s on regular API calls) --
|
|
230
239
|
private checkBackoff(): number | null {
|
|
231
240
|
const data = readJson<{ last_fail?: number; fail_count?: number }>(
|
|
232
241
|
BACKOFF_FILE,
|
|
@@ -257,16 +266,75 @@ class SoundCloudAPI {
|
|
|
257
266
|
if (fs.existsSync(BACKOFF_FILE)) fs.unlinkSync(BACKOFF_FILE);
|
|
258
267
|
}
|
|
259
268
|
|
|
260
|
-
|
|
269
|
+
// -- Token refresh backoff (separate, shorter max) --
|
|
270
|
+
private checkTokenBackoff(): number | null {
|
|
271
|
+
const data = readJson<{ last_fail?: number; fail_count?: number }>(
|
|
272
|
+
TOKEN_BACKOFF_FILE,
|
|
273
|
+
{}
|
|
274
|
+
);
|
|
275
|
+
if (!data.last_fail) return null;
|
|
276
|
+
const elapsed = Date.now() / 1000 - data.last_fail;
|
|
277
|
+
const backoff = Math.min(
|
|
278
|
+
TOKEN_BACKOFF_BASE_SECONDS * 2 ** (data.fail_count ?? 0),
|
|
279
|
+
TOKEN_BACKOFF_MAX_SECONDS
|
|
280
|
+
);
|
|
281
|
+
return elapsed < backoff ? Math.floor(backoff - elapsed) : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private setTokenBackoff(): void {
|
|
285
|
+
try {
|
|
286
|
+
const data = readJson<{ fail_count?: number }>(TOKEN_BACKOFF_FILE, {});
|
|
287
|
+
writeJson(TOKEN_BACKOFF_FILE, {
|
|
288
|
+
fail_count: (data.fail_count ?? 0) + 1,
|
|
289
|
+
last_fail: Date.now() / 1000,
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
/* best effort */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private clearTokenBackoff(): void {
|
|
297
|
+
if (fs.existsSync(TOKEN_BACKOFF_FILE)) fs.unlinkSync(TOKEN_BACKOFF_FILE);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// -- JWT helpers for proactive refresh --
|
|
301
|
+
private getTokenExpiry(): number | null {
|
|
302
|
+
const token = this.config.accessToken;
|
|
303
|
+
if (!token) return null;
|
|
304
|
+
try {
|
|
305
|
+
const parts = token.split(".");
|
|
306
|
+
if (parts.length !== 3) return null;
|
|
307
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
|
308
|
+
return payload.exp ?? null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private isTokenExpiringSoon(): boolean {
|
|
315
|
+
const exp = this.getTokenExpiry();
|
|
316
|
+
if (!exp) return true; // No token or can't decode = refresh
|
|
317
|
+
const now = Date.now() / 1000;
|
|
318
|
+
return now > exp - TOKEN_REFRESH_BUFFER_SECONDS;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// -- Token refresh (with separate backoff, proactive check) --
|
|
322
|
+
async refreshToken(force = false): Promise<boolean> {
|
|
261
323
|
if (!this.config.clientId || !this.config.clientSecret) return false;
|
|
262
324
|
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
|
|
325
|
+
// Skip if token is still valid (unless forced, e.g., on 401)
|
|
326
|
+
if (!force && !this.isTokenExpiringSoon()) {
|
|
327
|
+
return true; // Token still good
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const remaining = this.checkTokenBackoff();
|
|
331
|
+
if (remaining && !force) {
|
|
332
|
+
this.log(`Token refresh in backoff (${remaining}s remaining), skipping`);
|
|
266
333
|
return false;
|
|
267
334
|
}
|
|
268
335
|
|
|
269
336
|
try {
|
|
337
|
+
this.log("Refreshing token...");
|
|
270
338
|
const body = new URLSearchParams({
|
|
271
339
|
grant_type: "client_credentials",
|
|
272
340
|
client_id: this.config.clientId,
|
|
@@ -279,25 +347,35 @@ class SoundCloudAPI {
|
|
|
279
347
|
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
280
348
|
});
|
|
281
349
|
if (resp.status === 429) {
|
|
282
|
-
this.
|
|
350
|
+
this.setTokenBackoff();
|
|
283
351
|
this.log("Token refresh rate limited (429)");
|
|
284
352
|
return false;
|
|
285
353
|
}
|
|
286
354
|
if (!resp.ok) {
|
|
355
|
+
this.setTokenBackoff();
|
|
287
356
|
this.log(`Token refresh failed: ${resp.status}`);
|
|
288
357
|
return false;
|
|
289
358
|
}
|
|
290
359
|
const result = (await resp.json()) as { access_token: string };
|
|
291
360
|
this.config.saveToken(result.access_token);
|
|
292
|
-
this.
|
|
293
|
-
this.log("Token refreshed");
|
|
361
|
+
this.clearTokenBackoff();
|
|
362
|
+
this.log("Token refreshed successfully");
|
|
294
363
|
return true;
|
|
295
364
|
} catch (e) {
|
|
296
|
-
this.
|
|
365
|
+
this.setTokenBackoff();
|
|
366
|
+
this.log(`Token refresh error: ${e}`);
|
|
297
367
|
return false;
|
|
298
368
|
}
|
|
299
369
|
}
|
|
300
370
|
|
|
371
|
+
// Proactive check - call before making API requests
|
|
372
|
+
async ensureValidToken(): Promise<boolean> {
|
|
373
|
+
if (this.isTokenExpiringSoon()) {
|
|
374
|
+
return this.refreshToken(false);
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
301
379
|
async get(
|
|
302
380
|
url: string,
|
|
303
381
|
params?: Record<string, string | number>,
|
|
@@ -332,7 +410,8 @@ class SoundCloudAPI {
|
|
|
332
410
|
});
|
|
333
411
|
|
|
334
412
|
if (resp.status === 401 && retry) {
|
|
335
|
-
|
|
413
|
+
this.log("Got 401, forcing token refresh...");
|
|
414
|
+
if (await this.refreshToken(true)) { // force=true bypasses backoff
|
|
336
415
|
return this.get(url, params, false);
|
|
337
416
|
}
|
|
338
417
|
}
|
|
@@ -784,9 +863,9 @@ export class SoundCloudWatcher {
|
|
|
784
863
|
}
|
|
785
864
|
|
|
786
865
|
private async ensureToken(): Promise<string | null> {
|
|
787
|
-
if
|
|
788
|
-
if (!(await this.api.
|
|
789
|
-
return "Failed to get access token. Check your clientId and clientSecret.";
|
|
866
|
+
// Proactive refresh: check if token exists AND is not expiring soon
|
|
867
|
+
if (!(await this.api.ensureValidToken())) {
|
|
868
|
+
return "Failed to get/refresh access token. Check your clientId and clientSecret.";
|
|
790
869
|
}
|
|
791
870
|
return null;
|
|
792
871
|
}
|