@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akilles/soundcloud-watcher",
3
- "version": "2.1.0",
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
  }
@@ -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
- async refreshToken(): Promise<boolean> {
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
- const remaining = this.checkBackoff();
264
- if (remaining) {
265
- this.log(`Token refresh in backoff (${remaining}s remaining)`);
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.setBackoff();
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.clearBackoff();
293
- this.log("Token refreshed");
361
+ this.clearTokenBackoff();
362
+ this.log("Token refreshed successfully");
294
363
  return true;
295
364
  } catch (e) {
296
- this.log(`Token refresh failed: ${e}`);
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
- if (await this.refreshToken()) {
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 (this.config.accessToken) return null;
788
- if (!(await this.api.refreshToken())) {
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
  }