@akilles/soundcloud-watcher 2.0.5 → 2.2.0

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/README.md CHANGED
@@ -48,6 +48,9 @@ Add your credentials:
48
48
  SOUNDCLOUD_CLIENT_ID=your_client_id
49
49
  SOUNDCLOUD_CLIENT_SECRET=your_client_secret
50
50
  MY_USERNAME=your_soundcloud_username
51
+
52
+ # Optional settings
53
+ INCLUDE_LINKS=true # Include URLs in notifications (default: true)
51
54
  ```
52
55
 
53
56
  ### 4. Restart & Verify
package/index.ts CHANGED
@@ -10,6 +10,7 @@ interface PluginConfig {
10
10
  checkIntervalHours: number;
11
11
  myTracksLimit: number;
12
12
  dormantDays: number;
13
+ includeLinks: boolean;
13
14
  sessionKey?: string;
14
15
  }
15
16
 
@@ -34,6 +35,7 @@ function loadConfig(): PluginConfig | null {
34
35
  checkIntervalHours: 6,
35
36
  myTracksLimit: 10,
36
37
  dormantDays: 90,
38
+ includeLinks: env.INCLUDE_LINKS !== 'false', // Default: true
37
39
  sessionKey: 'agent:main:main',
38
40
  };
39
41
  }
@@ -57,6 +59,7 @@ export default function register(api: any) {
57
59
  username: config.username,
58
60
  myTracksLimit: config.myTracksLimit,
59
61
  dormantDays: config.dormantDays,
62
+ includeLinks: config.includeLinks,
60
63
  logger: (...args: any[]) => logger.debug?.(...args) || console.log(...args),
61
64
  });
62
65
  return watcher;
@@ -2,7 +2,7 @@
2
2
  "id": "soundcloud-watcher",
3
3
  "name": "SoundCloud Watcher",
4
4
  "description": "Monitor your SoundCloud account and track artist releases",
5
- "version": "2.0.4",
5
+ "version": "2.1.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akilles/soundcloud-watcher",
3
- "version": "2.0.5",
3
+ "version": "2.2.0",
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
+ "dependencies": {
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
  // =============================================================================
@@ -56,12 +64,24 @@ export interface SoundCloudWatcherConfig {
56
64
  username: string;
57
65
  myTracksLimit?: number;
58
66
  dormantDays?: number;
67
+ includeLinks?: boolean; // Include URLs in notifications (default: true)
59
68
  logger?: (...args: any[]) => void;
60
69
  }
61
70
 
62
71
  interface UserInfo {
63
72
  username: string;
64
73
  display_name: string;
74
+ permalink_url?: string;
75
+ }
76
+
77
+ interface FollowerNotification {
78
+ type: 'new' | 'lost';
79
+ users: UserInfo[];
80
+ }
81
+
82
+ interface AccountNotifications {
83
+ followers: FollowerNotification[];
84
+ engagement: string[]; // likes, reposts, etc.
65
85
  }
66
86
 
67
87
  interface TrackStats {
@@ -215,6 +235,7 @@ class SoundCloudAPI {
215
235
  private log: (...args: any[]) => void
216
236
  ) {}
217
237
 
238
+ // -- API rate limit backoff (for 429s on regular API calls) --
218
239
  private checkBackoff(): number | null {
219
240
  const data = readJson<{ last_fail?: number; fail_count?: number }>(
220
241
  BACKOFF_FILE,
@@ -245,16 +266,75 @@ class SoundCloudAPI {
245
266
  if (fs.existsSync(BACKOFF_FILE)) fs.unlinkSync(BACKOFF_FILE);
246
267
  }
247
268
 
248
- 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> {
249
323
  if (!this.config.clientId || !this.config.clientSecret) return false;
250
324
 
251
- const remaining = this.checkBackoff();
252
- if (remaining) {
253
- 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`);
254
333
  return false;
255
334
  }
256
335
 
257
336
  try {
337
+ this.log("Refreshing token...");
258
338
  const body = new URLSearchParams({
259
339
  grant_type: "client_credentials",
260
340
  client_id: this.config.clientId,
@@ -267,25 +347,35 @@ class SoundCloudAPI {
267
347
  signal: AbortSignal.timeout(API_TIMEOUT_MS),
268
348
  });
269
349
  if (resp.status === 429) {
270
- this.setBackoff();
350
+ this.setTokenBackoff();
271
351
  this.log("Token refresh rate limited (429)");
272
352
  return false;
273
353
  }
274
354
  if (!resp.ok) {
355
+ this.setTokenBackoff();
275
356
  this.log(`Token refresh failed: ${resp.status}`);
276
357
  return false;
277
358
  }
278
359
  const result = (await resp.json()) as { access_token: string };
279
360
  this.config.saveToken(result.access_token);
280
- this.clearBackoff();
281
- this.log("Token refreshed");
361
+ this.clearTokenBackoff();
362
+ this.log("Token refreshed successfully");
282
363
  return true;
283
364
  } catch (e) {
284
- this.log(`Token refresh failed: ${e}`);
365
+ this.setTokenBackoff();
366
+ this.log(`Token refresh error: ${e}`);
285
367
  return false;
286
368
  }
287
369
  }
288
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
+
289
379
  async get(
290
380
  url: string,
291
381
  params?: Record<string, string | number>,
@@ -320,7 +410,8 @@ class SoundCloudAPI {
320
410
  });
321
411
 
322
412
  if (resp.status === 401 && retry) {
323
- if (await this.refreshToken()) {
413
+ this.log("Got 401, forcing token refresh...");
414
+ if (await this.refreshToken(true)) { // force=true bypasses backoff
324
415
  return this.get(url, params, false);
325
416
  }
326
417
  }
@@ -396,9 +487,11 @@ class SoundCloudAPI {
396
487
 
397
488
  for (const f of data.collection ?? []) {
398
489
  if (f && typeof f === "object" && "id" in f) {
490
+ const permalink = f.permalink ?? f.username ?? "unknown";
399
491
  followers[String(f.id)] = {
400
- username: f.permalink ?? f.username ?? "unknown",
492
+ username: permalink,
401
493
  display_name: f.full_name ?? f.username ?? "unknown",
494
+ permalink_url: f.permalink_url ?? `https://soundcloud.com/${permalink}`,
402
495
  };
403
496
  }
404
497
  }
@@ -442,12 +535,12 @@ class AccountWatcher {
442
535
  writeJson(ACCOUNT_DATA, this.data);
443
536
  }
444
537
 
445
- async check(): Promise<string[]> {
446
- const notifications: string[] = [];
538
+ async check(): Promise<AccountNotifications> {
539
+ const result: AccountNotifications = { followers: [], engagement: [] };
447
540
 
448
541
  if (!this.data.my_account) {
449
542
  const user = await this.api.resolve(this.config.myUsername);
450
- if (!user) return ["Failed to resolve SoundCloud user"];
543
+ if (!user) return result;
451
544
  this.data.my_account = {
452
545
  user_id: user.id,
453
546
  username: user.permalink ?? this.config.myUsername,
@@ -459,7 +552,7 @@ class AccountWatcher {
459
552
  const profile = await this.api.getUser(userId);
460
553
  if (!profile) {
461
554
  this.log("Failed to fetch profile, skipping account check");
462
- return notifications;
555
+ return result;
463
556
  }
464
557
 
465
558
  const currentCount = num(profile.followers_count);
@@ -479,23 +572,16 @@ class AccountWatcher {
479
572
  if (Object.keys(stored).length) {
480
573
  const newFollowers = Object.entries(currentFollowers)
481
574
  .filter(([uid]) => !stored[uid])
482
- .map(([, f]) => f.display_name);
575
+ .map(([, f]) => f);
483
576
  const lostFollowers = Object.entries(stored)
484
577
  .filter(([uid]) => !currentFollowers[uid])
485
- .map(([, f]) => f.display_name);
578
+ .map(([, f]) => f);
486
579
 
487
580
  if (newFollowers.length) {
488
- let names = newFollowers.slice(0, 3).join(", ");
489
- if (newFollowers.length > 3) names += ` +${newFollowers.length - 3} more`;
490
- notifications.push(
491
- `New follower${newFollowers.length > 1 ? "s" : ""}: **${names}**`
492
- );
581
+ result.followers.push({ type: 'new', users: newFollowers });
493
582
  }
494
583
  if (lostFollowers.length) {
495
- const names = lostFollowers.slice(0, 3).join(", ");
496
- notifications.push(
497
- `Lost follower${lostFollowers.length > 1 ? "s" : ""}: ${names}`
498
- );
584
+ result.followers.push({ type: 'lost', users: lostFollowers });
499
585
  }
500
586
  }
501
587
 
@@ -550,11 +636,11 @@ class AccountWatcher {
550
636
  let names = newLikerNames.slice(0, 3).join(", ");
551
637
  if (newLikerNames.length > 3)
552
638
  names += ` +${newLikerNames.length - 3} more`;
553
- notifications.push(`**${names}** liked '${title}'`);
639
+ result.engagement.push(`**${names}** liked '${title}'`);
554
640
  }
555
641
  if (unlikerNames.length) {
556
642
  const names = unlikerNames.slice(0, 3).join(", ");
557
- notifications.push(`${names} unliked '${title}'`);
643
+ result.engagement.push(`${names} unliked '${title}'`);
558
644
  }
559
645
  } else {
560
646
  stats.likers = prevLikers;
@@ -562,7 +648,7 @@ class AccountWatcher {
562
648
 
563
649
  const newReposts = currentReposts - (prev.reposts ?? 0);
564
650
  if (newReposts > 0) {
565
- notifications.push(
651
+ result.engagement.push(
566
652
  `'${title}' got ${newReposts} repost${newReposts > 1 ? "s" : ""}!`
567
653
  );
568
654
  }
@@ -579,7 +665,7 @@ class AccountWatcher {
579
665
  }
580
666
 
581
667
  this.save();
582
- return notifications;
668
+ return result;
583
669
  }
584
670
  }
585
671
 
@@ -764,20 +850,22 @@ export class SoundCloudWatcher {
764
850
  private api: SoundCloudAPI;
765
851
  private myTracksLimit: number;
766
852
  private dormantDays: number;
853
+ private includeLinks: boolean;
767
854
  private log: (...args: any[]) => void;
768
855
 
769
856
  constructor(opts: SoundCloudWatcherConfig) {
770
857
  this.log = opts.logger ?? console.log;
771
858
  this.myTracksLimit = opts.myTracksLimit ?? 10;
772
859
  this.dormantDays = opts.dormantDays ?? 90;
860
+ this.includeLinks = opts.includeLinks ?? true; // Default: include links
773
861
  this.config = new Config(opts.clientId, opts.clientSecret, opts.username);
774
862
  this.api = new SoundCloudAPI(this.config, this.log);
775
863
  }
776
864
 
777
865
  private async ensureToken(): Promise<string | null> {
778
- if (this.config.accessToken) return null;
779
- if (!(await this.api.refreshToken())) {
780
- 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.";
781
869
  }
782
870
  return null;
783
871
  }
@@ -827,11 +915,24 @@ export class SoundCloudWatcher {
827
915
  lines.push(`[${utcnow()}] Full check complete\n`);
828
916
 
829
917
  lines.push("--- Account ---");
830
- for (const n of accountNotifs) lines.push(` ${n}`);
831
- if (!accountNotifs.length) lines.push(" No updates");
918
+ for (const fn of accountNotifs.followers) {
919
+ lines.push(...this.formatFollowerNotification(fn).map(l => ` ${l}`));
920
+ }
921
+ for (const e of accountNotifs.engagement) {
922
+ lines.push(` ${e}`);
923
+ }
924
+ if (!accountNotifs.followers.length && !accountNotifs.engagement.length) {
925
+ lines.push(" No updates");
926
+ }
832
927
 
833
928
  lines.push("\n--- Artist Releases ---");
834
- for (const r of releases) lines.push(` ${r.artist}: ${r.title}`);
929
+ for (const r of releases) {
930
+ if (this.includeLinks && r.url) {
931
+ lines.push(` ${r.artist}: ${r.title} - ${r.url}`);
932
+ } else {
933
+ lines.push(` ${r.artist}: ${r.title}`);
934
+ }
935
+ }
835
936
  if (!releases.length) lines.push(" No new releases");
836
937
 
837
938
  lines.push(`\nAPI calls: ${this.api.calls}`);
@@ -868,6 +969,34 @@ export class SoundCloudWatcher {
868
969
  return tracker.list();
869
970
  }
870
971
 
972
+ private formatFollowerNotification(notif: FollowerNotification): string[] {
973
+ const lines: string[] = [];
974
+ const users = notif.users.slice(0, 5); // Max 5 users shown
975
+ const remaining = notif.users.length - users.length;
976
+
977
+ if (notif.type === 'new') {
978
+ lines.push(`New follower${notif.users.length > 1 ? 's' : ''}:`);
979
+ for (const u of users) {
980
+ if (this.includeLinks && u.permalink_url) {
981
+ lines.push(`- **${u.display_name}**: ${u.permalink_url}`);
982
+ } else {
983
+ lines.push(`- **${u.display_name}**`);
984
+ }
985
+ }
986
+ } else {
987
+ lines.push(`Lost follower${notif.users.length > 1 ? 's' : ''}:`);
988
+ for (const u of users) {
989
+ lines.push(`- ${u.display_name}`);
990
+ }
991
+ }
992
+
993
+ if (remaining > 0) {
994
+ lines.push(` ...and ${remaining} more`);
995
+ }
996
+
997
+ return lines;
998
+ }
999
+
871
1000
  async runCron(): Promise<string | null> {
872
1001
  const tokenErr = await this.ensureToken();
873
1002
  if (tokenErr) {
@@ -884,16 +1013,31 @@ export class SoundCloudWatcher {
884
1013
  ]);
885
1014
 
886
1015
  const lines: string[] = [];
887
- if (accountNotifs.length) {
1016
+
1017
+ // Format follower notifications
1018
+ const hasFollowerUpdates = accountNotifs.followers.length > 0;
1019
+ const hasEngagement = accountNotifs.engagement.length > 0;
1020
+
1021
+ if (hasFollowerUpdates || hasEngagement) {
888
1022
  lines.push("**Account:**");
889
- lines.push(...accountNotifs.map((n) => `- ${n}`));
1023
+ for (const fn of accountNotifs.followers) {
1024
+ lines.push(...this.formatFollowerNotification(fn));
1025
+ }
1026
+ for (const e of accountNotifs.engagement) {
1027
+ lines.push(`- ${e}`);
1028
+ }
890
1029
  lines.push("");
891
1030
  }
1031
+
892
1032
  if (releases.length) {
893
1033
  lines.push("**New Releases:**");
894
1034
  for (const r of releases) {
895
- lines.push(`- **${r.artist}** dropped: ${r.title}`);
896
- lines.push(` ${r.url}`);
1035
+ if (this.includeLinks && r.url) {
1036
+ lines.push(`- **${r.artist}** dropped: ${r.title}`);
1037
+ lines.push(` ${r.url}`);
1038
+ } else {
1039
+ lines.push(`- **${r.artist}** dropped: ${r.title}`);
1040
+ }
897
1041
  }
898
1042
  lines.push("");
899
1043
  }