@akilles/soundcloud-watcher 1.0.3 → 2.0.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/index.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { spawn } from 'child_process';
2
- import { join } from 'path';
1
+ import { SoundCloudWatcher } from './soundcloud_watcher';
3
2
 
4
- interface SoundCloudConfig {
3
+ interface PluginConfig {
5
4
  enabled: boolean;
6
5
  clientId: string;
7
6
  clientSecret: string;
@@ -9,58 +8,30 @@ interface SoundCloudConfig {
9
8
  checkIntervalHours: number;
10
9
  myTracksLimit: number;
11
10
  dormantDays: number;
12
- sessionKey?: string; // Where to send notifications (defaults to 'agent:main:main')
11
+ sessionKey?: string;
13
12
  }
14
13
 
15
14
  export default function register(api: any) {
16
15
  const logger = api.getLogger?.() || console;
17
16
  let checkInterval: NodeJS.Timeout | null = null;
18
-
19
- // Path to the TypeScript watcher script
20
- const scriptPath = join(__dirname, 'soundcloud_watcher.ts');
21
-
22
- /**
23
- * Execute the TypeScript watcher script with given arguments
24
- */
25
- async function runWatcherScript(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
26
- return new Promise((resolve) => {
27
- // Use tsx to run the file directly
28
- const proc = spawn('npx', ['tsx', scriptPath, ...args], {
29
- cwd: __dirname,
30
- env: {
31
- ...process.env,
32
- // Pass config via environment vars
33
- SOUNDCLOUD_CLIENT_ID: api.getConfig?.()?.clientId || '',
34
- SOUNDCLOUD_CLIENT_SECRET: api.getConfig?.()?.clientSecret || '',
35
- MY_USERNAME: api.getConfig?.()?.username || '',
36
- },
37
- });
38
-
39
- let stdout = '';
40
- let stderr = '';
41
-
42
- proc.stdout?.on('data', (data) => {
43
- stdout += data.toString();
44
- });
45
-
46
- proc.stderr?.on('data', (data) => {
47
- stderr += data.toString();
17
+ let watcher: SoundCloudWatcher | null = null;
18
+
19
+ function getWatcher(): SoundCloudWatcher {
20
+ if (!watcher) {
21
+ const config = api.getConfig() as PluginConfig;
22
+ watcher = new SoundCloudWatcher({
23
+ clientId: config.clientId || '',
24
+ clientSecret: config.clientSecret || '',
25
+ username: config.username || '',
26
+ myTracksLimit: config.myTracksLimit,
27
+ dormantDays: config.dormantDays,
28
+ logger: (...args: any[]) => logger.debug(...args),
48
29
  });
49
-
50
- proc.on('close', (code) => {
51
- resolve({ stdout, stderr, code: code || 0 });
52
- });
53
-
54
- proc.on('error', (err) => {
55
- resolve({ stdout, stderr: err.message, code: 1 });
56
- });
57
- });
30
+ }
31
+ return watcher;
58
32
  }
59
33
 
60
- /**
61
- * Run a check for SoundCloud updates
62
- */
63
- async function checkForUpdates(config: SoundCloudConfig, sessionKey?: string) {
34
+ async function checkForUpdates(config: PluginConfig, sessionKey?: string) {
64
35
  if (!config.enabled) {
65
36
  logger.debug('SoundCloud watcher is disabled');
66
37
  return;
@@ -68,23 +39,16 @@ export default function register(api: any) {
68
39
 
69
40
  try {
70
41
  logger.info('Running SoundCloud check...');
71
- const result = await runWatcherScript(['cron']);
72
-
73
- if (result.code !== 0) {
74
- logger.error('SoundCloud check failed:', result.stderr || result.stdout);
75
- return;
76
- }
42
+ const message = await getWatcher().runCron();
77
43
 
78
- // The script outputs notifications to stdout in cron mode
79
- if (result.stdout.trim()) {
80
- logger.info('SoundCloud updates found:', result.stdout);
44
+ if (message) {
45
+ logger.info('SoundCloud updates found');
81
46
 
82
- // If a session key is provided, send the notification there
83
47
  if (sessionKey) {
84
48
  try {
85
49
  await api.tools.sessions_send({
86
50
  sessionKey,
87
- message: result.stdout.trim(),
51
+ message,
88
52
  });
89
53
  } catch (err) {
90
54
  logger.error('Failed to send notification:', err);
@@ -98,22 +62,17 @@ export default function register(api: any) {
98
62
  }
99
63
  }
100
64
 
101
- /**
102
- * Start the periodic check loop
103
- */
104
- function startChecking(config: SoundCloudConfig, sessionKey?: string) {
65
+ function startChecking(config: PluginConfig, sessionKey?: string) {
105
66
  if (checkInterval) {
106
67
  clearInterval(checkInterval);
107
68
  }
108
69
 
109
70
  const intervalMs = config.checkIntervalHours * 60 * 60 * 1000;
110
71
 
111
- // Run immediately on startup
112
72
  checkForUpdates(config, sessionKey).catch((err) => {
113
73
  logger.error('Initial SoundCloud check failed:', err);
114
74
  });
115
75
 
116
- // Then run periodically
117
76
  checkInterval = setInterval(() => {
118
77
  checkForUpdates(config, sessionKey).catch((err) => {
119
78
  logger.error('Periodic SoundCloud check failed:', err);
@@ -123,9 +82,6 @@ export default function register(api: any) {
123
82
  logger.info(`SoundCloud watcher started (checking every ${config.checkIntervalHours}h)`);
124
83
  }
125
84
 
126
- /**
127
- * Stop the periodic check loop
128
- */
129
85
  function stopChecking() {
130
86
  if (checkInterval) {
131
87
  clearInterval(checkInterval);
@@ -139,12 +95,12 @@ export default function register(api: any) {
139
95
  name: 'soundcloud-setup',
140
96
  description: 'Interactive setup for SoundCloud credentials',
141
97
  handler: async (ctx: any) => {
142
- const config = api.getConfig() as SoundCloudConfig;
98
+ const config = api.getConfig() as PluginConfig;
143
99
 
144
100
  let message = '# SoundCloud Watcher Setup\n\n';
145
101
 
146
102
  if (config.clientId && config.clientSecret && config.username) {
147
- message += 'Already configured!\n\n';
103
+ message += 'Already configured!\n\n';
148
104
  message += `- Username: ${config.username}\n`;
149
105
  message += `- Client ID: ${config.clientId.substring(0, 8)}...${config.clientId.slice(-4)}\n`;
150
106
  message += `- Check interval: ${config.checkIntervalHours} hours\n`;
@@ -175,7 +131,7 @@ export default function register(api: any) {
175
131
  message += '}\n';
176
132
  message += '```\n\n';
177
133
  message += '3. Restart: `openclaw gateway restart`\n';
178
- message += '4. Run: `/soundcloud-status` to verify';
134
+ message += '4. Verify in chat: `/soundcloud-setup`\n';
179
135
  }
180
136
 
181
137
  return { text: message };
@@ -186,8 +142,8 @@ export default function register(api: any) {
186
142
  name: 'soundcloud-status',
187
143
  description: 'Show SoundCloud watcher status',
188
144
  handler: async (ctx: any) => {
189
- const result = await runWatcherScript(['status']);
190
- return { text: result.stdout || result.stderr };
145
+ const result = await getWatcher().status();
146
+ return { text: result };
191
147
  },
192
148
  });
193
149
 
@@ -195,8 +151,8 @@ export default function register(api: any) {
195
151
  name: 'soundcloud-check',
196
152
  description: 'Run an immediate SoundCloud check',
197
153
  handler: async (ctx: any) => {
198
- const result = await runWatcherScript(['check']);
199
- return { text: result.stdout || result.stderr };
154
+ const result = await getWatcher().check();
155
+ return { text: result };
200
156
  },
201
157
  });
202
158
 
@@ -207,8 +163,8 @@ export default function register(api: any) {
207
163
  if (usernames.length === 0) {
208
164
  return { text: 'Usage: /soundcloud-add <username> [username2] ...' };
209
165
  }
210
- const result = await runWatcherScript(['add', ...usernames]);
211
- return { text: result.stdout || result.stderr };
166
+ const result = await getWatcher().addArtists(usernames);
167
+ return { text: result };
212
168
  },
213
169
  });
214
170
 
@@ -219,8 +175,8 @@ export default function register(api: any) {
219
175
  if (!username) {
220
176
  return { text: 'Usage: /soundcloud-remove <username>' };
221
177
  }
222
- const result = await runWatcherScript(['remove', username]);
223
- return { text: result.stdout || result.stderr };
178
+ const result = await getWatcher().removeArtist(username);
179
+ return { text: result };
224
180
  },
225
181
  });
226
182
 
@@ -228,13 +184,14 @@ export default function register(api: any) {
228
184
  name: 'soundcloud-list',
229
185
  description: 'List all tracked artists',
230
186
  handler: async (ctx: any) => {
231
- const result = await runWatcherScript(['list']);
232
- return { text: result.stdout || result.stderr };
187
+ const result = await getWatcher().listArtists();
188
+ return { text: result };
233
189
  },
234
190
  });
235
191
 
236
192
  // Handle config changes
237
- api.onConfigChange?.((config: SoundCloudConfig) => {
193
+ api.onConfigChange?.((config: PluginConfig) => {
194
+ watcher = null; // Reset watcher so it picks up new config
238
195
  if (config.enabled) {
239
196
  const sessionKey = config.sessionKey || 'agent:main:main';
240
197
  startChecking(config, sessionKey);
@@ -244,7 +201,7 @@ export default function register(api: any) {
244
201
  });
245
202
 
246
203
  // Initialize on load
247
- const initialConfig = api.getConfig() as SoundCloudConfig;
204
+ const initialConfig = api.getConfig() as PluginConfig;
248
205
  if (initialConfig.enabled) {
249
206
  const sessionKey = initialConfig.sessionKey || 'agent:main:main';
250
207
  startChecking(initialConfig, sessionKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akilles/soundcloud-watcher",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "preinstall": "echo 'Installing SoundCloud Watcher dependencies...'",
13
- "postinstall": "echo 'SoundCloud Watcher installed! Run /soundcloud-setup to configure.'",
13
+ "postinstall": "echo '\n✓ SoundCloud Watcher installed!\n\nNext steps:\n1. Get API credentials: https://soundcloud.com/you/apps\n2. Add to ~/.openclaw/openclaw.json:\n\n \"soundcloud-watcher\": {\n \"enabled\": true,\n \"config\": {\n \"clientId\": \"YOUR_CLIENT_ID\",\n \"clientSecret\": \"YOUR_CLIENT_SECRET\",\n \"username\": \"your_soundcloud_username\"\n }\n }\n\n3. Restart: openclaw gateway restart\n4. Verify in chat: /soundcloud-setup\n'",
14
14
  "test": "echo \"No tests yet\""
15
15
  },
16
16
  "keywords": [
@@ -39,9 +39,6 @@
39
39
  "README.md",
40
40
  "LICENSE"
41
41
  ],
42
- "dependencies": {
43
- "tsx": "^4.0.0"
44
- },
45
42
  "peerDependencies": {
46
43
  "@types/node": "^22.0.0"
47
44
  },
@@ -1,6 +1,5 @@
1
- #!/usr/bin/env npx tsx
2
1
  /**
3
- * SoundCloud Watcher: Cron script for tracking your SoundCloud account
2
+ * SoundCloud Watcher: Module for tracking your SoundCloud account
4
3
  * and getting notified about new releases from artists you care about.
5
4
  *
6
5
  * Features:
@@ -9,21 +8,8 @@
9
8
  * - New release detection from a curated artist list
10
9
  * - Dormant artist throttling (skip inactive artists to save API calls)
11
10
  * - Rate limit backoff (exponential backoff on 429s)
12
- * - Single cron entry runs everything
13
11
  *
14
- * Setup:
15
- * 1. Create config file (see PATHS below) with your SoundCloud API credentials
16
- * 2. Run: npx tsx soundcloud_cron.ts add <artist_username>
17
- * 3. Run: npx tsx soundcloud_cron.ts check
18
- * 4. Add to cron: 0 * /6 * * * npx tsx /path/to/soundcloud_cron.ts cron
19
- *
20
- * Config file format (one KEY=VALUE per line):
21
- * SOUNDCLOUD_CLIENT_ID=your_client_id
22
- * SOUNDCLOUD_CLIENT_SECRET=your_client_secret
23
- * SOUNDCLOUD_ACCESS_TOKEN=auto_managed_by_script
24
- * MY_USERNAME=your_soundcloud_username
25
- *
26
- * Requirements: Node.js 22+ (uses built-in fetch), npx tsx (or ts-node)
12
+ * Exported as a class for direct import (no subprocess spawning).
27
13
  */
28
14
 
29
15
  import * as fs from "fs";
@@ -31,72 +17,48 @@ import * as path from "path";
31
17
  import * as os from "os";
32
18
 
33
19
  // =============================================================================
34
- // CONFIGURATION - edit these to match your setup
20
+ // CONFIGURATION
35
21
  // =============================================================================
36
22
 
37
- /** Your SoundCloud username (the URL slug, e.g. soundcloud.com/THIS_PART) */
38
- const MY_USERNAME = "your_username";
39
-
40
- /** Where to store config and data files - using OpenClaw standard paths */
41
23
  const OPENCLAW_DIR = path.join(os.homedir(), ".openclaw");
42
24
  const CONFIG_FILE = path.join(OPENCLAW_DIR, "secrets", "soundcloud.env");
43
25
  const ACCOUNT_DATA = path.join(OPENCLAW_DIR, "data", "soundcloud_tracking.json");
44
26
  const ARTISTS_DATA = path.join(OPENCLAW_DIR, "data", "artists.json");
45
27
  const BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_backoff.json");
46
28
 
47
- // --- Notification settings ---
48
- /** Set to true to send notifications via a gateway, false for stdout only */
49
- const NOTIFICATIONS_ENABLED = false;
50
- const GATEWAY_CONFIG = path.join(OPENCLAW_DIR, "gateway.json");
51
- const GATEWAY_SESSION_KEY = "default";
52
- const GATEWAY_PORT_DEFAULT = 8080;
53
- const GATEWAY_ENDPOINT = "/tools/invoke";
54
- const GATEWAY_TOOL_NAME = "sessions_send";
55
-
56
29
  // =============================================================================
57
- // TUNING - adjust these to balance API usage vs responsiveness
30
+ // TUNING
58
31
  // =============================================================================
59
32
 
60
- /** How many of YOUR recent tracks to monitor for likes/reposts */
61
- const MY_TRACKS_LIMIT = 10;
62
-
63
- /** How many recent tracks to fetch per artist when checking for new releases */
64
33
  const ARTIST_TRACKS_LIMIT = 5;
65
-
66
- /** How many tracks to fetch when first adding an artist (seeds known tracks) */
67
34
  const ARTIST_ADD_LIMIT = 50;
68
-
69
- /** Artists who haven't uploaded in this many days are considered "dormant" */
70
- const DORMANT_DAYS = 90;
71
-
72
- /** Dormant artists are only checked every N days instead of every run */
73
35
  const DORMANT_CHECK_INTERVAL_DAYS = 7;
74
-
75
- /** Max stored track IDs per artist (older ones pruned to save disk/memory) */
76
36
  const MAX_KNOWN_TRACKS = 50;
77
-
78
- /** Max likers to fetch per track */
79
37
  const MAX_LIKERS_PER_TRACK = 50;
80
-
81
- /** Followers pagination page size (SoundCloud max is 200) */
82
38
  const FOLLOWERS_PAGE_SIZE = 200;
83
39
 
84
- // --- Rate limit backoff ---
85
- const BACKOFF_BASE_SECONDS = 300; // 5 min initial backoff after a 429
86
- const BACKOFF_MAX_SECONDS = 7200; // 2 hour ceiling
40
+ const BACKOFF_BASE_SECONDS = 300;
41
+ const BACKOFF_MAX_SECONDS = 7200;
87
42
 
88
- // --- Timeouts ---
89
43
  const API_TIMEOUT_MS = 15_000;
90
- const GATEWAY_TIMEOUT_MS = 60_000;
91
44
 
92
45
  // =============================================================================
93
- // INTERNALS - you probably don't need to change anything below
46
+ // INTERNALS
94
47
  // =============================================================================
95
48
 
96
49
  const API_BASE = "https://api.soundcloud.com";
97
50
 
98
51
  // -- Types --------------------------------------------------------------------
99
52
 
53
+ export interface SoundCloudWatcherConfig {
54
+ clientId: string;
55
+ clientSecret: string;
56
+ username: string;
57
+ myTracksLimit?: number;
58
+ dormantDays?: number;
59
+ logger?: (...args: any[]) => void;
60
+ }
61
+
100
62
  interface UserInfo {
101
63
  username: string;
102
64
  display_name: string;
@@ -159,20 +121,14 @@ function daysSince(isoDate: string): number | null {
159
121
  return isNaN(ms) ? null : Math.floor(ms / 86_400_000);
160
122
  }
161
123
 
162
- /**
163
- * Parse SoundCloud timestamps into Unix ms.
164
- * SoundCloud returns dates like: "2026/01/22 16:22:27 +0000"
165
- */
166
124
  function parseTimestamp(ts: string | null | undefined): number {
167
125
  if (!ts) return NaN;
168
126
  try {
169
- // "2026/01/22 16:22:27 +0000" "2026-01-22T16:22:27+00:00"
170
- let cleaned = ts.replace(/\//g, "-").replace(" ", "T"); // first space only
127
+ let cleaned = ts.replace(/\//g, "-").replace(" ", "T");
171
128
  cleaned = cleaned.replace(" +0000", "+00:00").replace("Z", "+00:00");
172
129
  const d = new Date(cleaned);
173
130
  return d.getTime();
174
131
  } catch {
175
- console.log(`Warning: Could not parse timestamp '${ts}'`);
176
132
  return NaN;
177
133
  }
178
134
  }
@@ -205,26 +161,26 @@ function num(val: unknown): number {
205
161
  // -- Config -------------------------------------------------------------------
206
162
 
207
163
  class Config {
208
- clientId = "";
209
- clientSecret = "";
164
+ clientId: string;
165
+ clientSecret: string;
210
166
  accessToken = "";
211
- myUsername = MY_USERNAME;
212
-
213
- static load(): Config {
214
- const cfg = new Config();
215
- if (!fs.existsSync(CONFIG_FILE)) return cfg;
216
-
217
- for (const line of fs.readFileSync(CONFIG_FILE, "utf-8").split("\n")) {
218
- if (!line.includes("=") || line.startsWith("#")) continue;
219
- const [k, ...rest] = line.split("=");
220
- const key = k.trim();
221
- const val = rest.join("=").trim();
222
- if (key === "SOUNDCLOUD_CLIENT_ID") cfg.clientId = val;
223
- else if (key === "SOUNDCLOUD_CLIENT_SECRET") cfg.clientSecret = val;
224
- else if (key === "SOUNDCLOUD_ACCESS_TOKEN") cfg.accessToken = val;
225
- else if (key === "MY_USERNAME") cfg.myUsername = val;
167
+ myUsername: string;
168
+
169
+ constructor(clientId: string, clientSecret: string, username: string) {
170
+ this.clientId = clientId;
171
+ this.clientSecret = clientSecret;
172
+ this.myUsername = username;
173
+
174
+ // Load persisted access token from env file if it exists
175
+ if (fs.existsSync(CONFIG_FILE)) {
176
+ for (const line of fs.readFileSync(CONFIG_FILE, "utf-8").split("\n")) {
177
+ if (!line.includes("=") || line.startsWith("#")) continue;
178
+ const [k, ...rest] = line.split("=");
179
+ if (k.trim() === "SOUNDCLOUD_ACCESS_TOKEN") {
180
+ this.accessToken = rest.join("=").trim();
181
+ }
182
+ }
226
183
  }
227
- return cfg;
228
184
  }
229
185
 
230
186
  saveToken(token: string): void {
@@ -251,7 +207,10 @@ class Config {
251
207
  class SoundCloudAPI {
252
208
  calls = 0;
253
209
 
254
- constructor(private config: Config) {}
210
+ constructor(
211
+ private config: Config,
212
+ private log: (...args: any[]) => void
213
+ ) {}
255
214
 
256
215
  private checkBackoff(): number | null {
257
216
  const data = readJson<{ last_fail?: number; fail_count?: number }>(
@@ -288,7 +247,7 @@ class SoundCloudAPI {
288
247
 
289
248
  const remaining = this.checkBackoff();
290
249
  if (remaining) {
291
- console.log(`Token refresh in backoff (${remaining}s remaining)`);
250
+ this.log(`Token refresh in backoff (${remaining}s remaining)`);
292
251
  return false;
293
252
  }
294
253
 
@@ -306,25 +265,24 @@ class SoundCloudAPI {
306
265
  });
307
266
  if (resp.status === 429) {
308
267
  this.setBackoff();
309
- console.log("Token refresh rate limited (429)");
268
+ this.log("Token refresh rate limited (429)");
310
269
  return false;
311
270
  }
312
271
  if (!resp.ok) {
313
- console.log(`Token refresh failed: ${resp.status}`);
272
+ this.log(`Token refresh failed: ${resp.status}`);
314
273
  return false;
315
274
  }
316
275
  const result = (await resp.json()) as { access_token: string };
317
276
  this.config.saveToken(result.access_token);
318
277
  this.clearBackoff();
319
- console.log("Token refreshed");
278
+ this.log("Token refreshed");
320
279
  return true;
321
280
  } catch (e) {
322
- console.log(`Token refresh failed: ${e}`);
281
+ this.log(`Token refresh failed: ${e}`);
323
282
  return false;
324
283
  }
325
284
  }
326
285
 
327
- /** Make authenticated GET request. Accepts relative (/users/...) or full URLs. */
328
286
  async get(
329
287
  url: string,
330
288
  params?: Record<string, string | number>,
@@ -364,29 +322,26 @@ class SoundCloudAPI {
364
322
  }
365
323
  }
366
324
  if (!resp.ok) {
367
- console.log(`API error ${resp.status}: ${fullUrl}`);
325
+ this.log(`API error ${resp.status}: ${fullUrl}`);
368
326
  return null;
369
327
  }
370
328
  return (await resp.json()) as Record<string, any>;
371
329
  } catch (e) {
372
- console.log(`API error: ${e}`);
330
+ this.log(`API error: ${e}`);
373
331
  return null;
374
332
  }
375
333
  }
376
334
 
377
- /** Resolve username to user object. */
378
335
  resolve(username: string) {
379
336
  return this.get("/resolve", {
380
337
  url: `https://soundcloud.com/${username}`,
381
338
  });
382
339
  }
383
340
 
384
- /** Get user profile by ID (includes followers_count). */
385
341
  getUser(userId: number) {
386
342
  return this.get(`/users/${userId}`);
387
343
  }
388
344
 
389
- /** Get a user's tracks. Response includes play/like/repost counts per track. */
390
345
  async getTracks(userId: number, limit = 20): Promise<Record<string, any>[]> {
391
346
  const data = await this.get(`/users/${userId}/tracks`, {
392
347
  limit,
@@ -397,7 +352,6 @@ class SoundCloudAPI {
397
352
  return Array.isArray(collection) ? collection : [];
398
353
  }
399
354
 
400
- /** Get users who liked a specific track. */
401
355
  async getTrackLikers(
402
356
  trackId: number,
403
357
  limit = MAX_LIKERS_PER_TRACK
@@ -423,7 +377,6 @@ class SoundCloudAPI {
423
377
  return likers;
424
378
  }
425
379
 
426
- /** Paginate through all followers. Expensive - only call when follower count changes. */
427
380
  async getFollowersPaginated(
428
381
  userId: number
429
382
  ): Promise<Record<string, UserInfo>> {
@@ -449,7 +402,7 @@ class SoundCloudAPI {
449
402
 
450
403
  const nextHref = data.next_href;
451
404
  if (nextHref && nextHref !== nextUrl) {
452
- nextUrl = nextHref; // Full URL with cursor params included
405
+ nextUrl = nextHref;
453
406
  params = undefined;
454
407
  } else {
455
408
  break;
@@ -466,7 +419,9 @@ class AccountWatcher {
466
419
 
467
420
  constructor(
468
421
  private api: SoundCloudAPI,
469
- private config: Config
422
+ private config: Config,
423
+ private myTracksLimit: number,
424
+ private log: (...args: any[]) => void
470
425
  ) {
471
426
  const defaults: AccountState = {
472
427
  my_account: null,
@@ -484,16 +439,9 @@ class AccountWatcher {
484
439
  writeJson(ACCOUNT_DATA, this.data);
485
440
  }
486
441
 
487
- /**
488
- * Run account check. Returns list of human-readable notification strings.
489
- *
490
- * API calls on a quiet day: 2 (profile + tracks)
491
- * API calls on follower change: 2 + ceil(followers/200) + tracks_with_new_likes
492
- */
493
442
  async check(): Promise<string[]> {
494
443
  const notifications: string[] = [];
495
444
 
496
- // Resolve account on first run
497
445
  if (!this.data.my_account) {
498
446
  const user = await this.api.resolve(this.config.myUsername);
499
447
  if (!user) return ["Failed to resolve SoundCloud user"];
@@ -505,29 +453,26 @@ class AccountWatcher {
505
453
 
506
454
  const userId = this.data.my_account.user_id;
507
455
 
508
- // Fetch profile to check follower count (1 API call)
509
456
  const profile = await this.api.getUser(userId);
510
457
  if (!profile) {
511
- console.log("Failed to fetch profile, skipping account check");
512
- return notifications; // Don't save - preserve previous state
458
+ this.log("Failed to fetch profile, skipping account check");
459
+ return notifications;
513
460
  }
514
461
 
515
462
  const currentCount = num(profile.followers_count);
516
463
  const storedCount = this.data.follower_count;
517
464
 
518
- // Only paginate full follower list if the count actually changed
519
465
  if (currentCount !== storedCount || !Object.keys(this.data.my_followers).length) {
520
- console.log(
466
+ this.log(
521
467
  `Follower count changed: ${storedCount} -> ${currentCount}, fetching list...`
522
468
  );
523
469
  const currentFollowers = await this.api.getFollowersPaginated(userId);
524
470
 
525
471
  if (!Object.keys(currentFollowers).length && storedCount > 0) {
526
- console.log("API returned empty followers, skipping comparison");
472
+ this.log("API returned empty followers, skipping comparison");
527
473
  } else {
528
474
  const stored = this.data.my_followers;
529
475
 
530
- // Skip diff on first run (everything would show as "new")
531
476
  if (Object.keys(stored).length) {
532
477
  const newFollowers = Object.entries(currentFollowers)
533
478
  .filter(([uid]) => !stored[uid])
@@ -555,11 +500,10 @@ class AccountWatcher {
555
500
  this.data.follower_count = currentCount;
556
501
  }
557
502
  } else {
558
- console.log(`Follower count unchanged (${currentCount}), skipping pagination`);
503
+ this.log(`Follower count unchanged (${currentCount}), skipping pagination`);
559
504
  }
560
505
 
561
- // Fetch my tracks - play/like/repost counts included in response (1 API call)
562
- const tracks = await this.api.getTracks(userId, MY_TRACKS_LIMIT);
506
+ const tracks = await this.api.getTracks(userId, this.myTracksLimit);
563
507
  if (tracks.length) {
564
508
  const prevMap = new Map(this.data.track_stats.map((s) => [s.track_id, s]));
565
509
  const newStats: TrackStats[] = [];
@@ -584,7 +528,6 @@ class AccountWatcher {
584
528
  const prevLikes = prev.likes;
585
529
  const prevLikers = prev.likers ?? {};
586
530
 
587
- // Only fetch liker list if like count changed (or never seeded)
588
531
  const needsLikerFetch =
589
532
  currentLikes !== prevLikes ||
590
533
  (currentLikes > 0 && !Object.keys(prevLikers).length);
@@ -611,7 +554,6 @@ class AccountWatcher {
611
554
  notifications.push(`${names} unliked '${title}'`);
612
555
  }
613
556
  } else {
614
- // No change - carry forward previous liker data
615
557
  stats.likers = prevLikers;
616
558
  }
617
559
 
@@ -622,7 +564,6 @@ class AccountWatcher {
622
564
  );
623
565
  }
624
566
  } else {
625
- // First time seeing this track - seed likers without notifying
626
567
  stats.likers = await this.api.getTrackLikers(trackId);
627
568
  }
628
569
 
@@ -631,7 +572,7 @@ class AccountWatcher {
631
572
 
632
573
  this.data.track_stats = newStats;
633
574
  } else {
634
- console.log("Failed to fetch tracks, keeping previous stats");
575
+ this.log("Failed to fetch tracks, keeping previous stats");
635
576
  }
636
577
 
637
578
  this.save();
@@ -644,7 +585,11 @@ class AccountWatcher {
644
585
  class ArtistTracker {
645
586
  data: ArtistsState;
646
587
 
647
- constructor(private api: SoundCloudAPI) {
588
+ constructor(
589
+ private api: SoundCloudAPI,
590
+ private dormantDays: number,
591
+ private log: (...args: any[]) => void
592
+ ) {
648
593
  this.data = readJson<ArtistsState>(ARTISTS_DATA, {
649
594
  artists: {},
650
595
  updated_at: null,
@@ -658,7 +603,7 @@ class ArtistTracker {
658
603
 
659
604
  private isDormant(artist: ArtistData): boolean {
660
605
  const days = daysSince(artist.last_upload ?? "");
661
- return days !== null && days > DORMANT_DAYS;
606
+ return days !== null && days > this.dormantDays;
662
607
  }
663
608
 
664
609
  private shouldSkip(artist: ArtistData): boolean {
@@ -667,10 +612,6 @@ class ArtistTracker {
667
612
  return days !== null && days < DORMANT_CHECK_INTERVAL_DAYS;
668
613
  }
669
614
 
670
- /**
671
- * Check all tracked artists for new releases.
672
- * API calls: 1 per active artist, 1 per dormant artist due for check, 0 for skipped.
673
- */
674
615
  async checkReleases(): Promise<ReleaseNotification[]> {
675
616
  const notifications: ReleaseNotification[] = [];
676
617
  let checked = 0;
@@ -715,7 +656,6 @@ class ArtistTracker {
715
656
  }
716
657
  }
717
658
 
718
- // Prune old track IDs to prevent unbounded growth
719
659
  const ids = this.data.artists[username].known_track_ids ?? [];
720
660
  if (ids.length > MAX_KNOWN_TRACKS) {
721
661
  this.data.artists[username].known_track_ids = ids.slice(-MAX_KNOWN_TRACKS);
@@ -725,7 +665,7 @@ class ArtistTracker {
725
665
  const dormantCount = Object.values(this.data.artists).filter((a) =>
726
666
  this.isDormant(a)
727
667
  ).length;
728
- console.log(
668
+ this.log(
729
669
  `Checked ${checked} artists, skipped ${skipped} dormant, ${dormantCount} total dormant`
730
670
  );
731
671
 
@@ -733,7 +673,6 @@ class ArtistTracker {
733
673
  return notifications;
734
674
  }
735
675
 
736
- /** Add an artist to tracking. Seeds known tracks to avoid false notifications. */
737
676
  async add(username: string): Promise<string> {
738
677
  const user = await this.api.resolve(username);
739
678
  if (!user) return `Could not find user: ${username}`;
@@ -780,7 +719,6 @@ class ArtistTracker {
780
719
  return `Added ${user.full_name || username} (${followers.toLocaleString()} followers, ${tracks.length} tracks)`;
781
720
  }
782
721
 
783
- /** Remove an artist from tracking. */
784
722
  remove(username: string): string {
785
723
  const key = username.toLowerCase();
786
724
  for (const [k, artist] of Object.entries(this.data.artists)) {
@@ -794,161 +732,153 @@ class ArtistTracker {
794
732
  return `Artist '${username}' not found`;
795
733
  }
796
734
 
797
- /** Print all tracked artists sorted by follower count. */
798
- list(): void {
735
+ list(): string {
799
736
  const artists = Object.values(this.data.artists).sort(
800
737
  (a, b) => (b.followers ?? 0) - (a.followers ?? 0)
801
738
  );
802
- console.log(`\n=== Tracked Artists (${artists.length}) ===\n`);
739
+ const lines: string[] = [];
740
+ lines.push(`\n=== Tracked Artists (${artists.length}) ===\n`);
803
741
  for (const a of artists) {
804
742
  const dormant = this.isDormant(a);
805
743
  const status = dormant ? " [DORMANT]" : "";
806
- console.log(`${a.display_name} (@${a.username})${status}`);
807
- console.log(
744
+ lines.push(`${a.display_name} (@${a.username})${status}`);
745
+ lines.push(
808
746
  ` ${(a.followers ?? 0).toLocaleString()} followers | ${a.track_count ?? 0} tracks`
809
747
  );
810
- if (a.last_upload) console.log(` Last upload: ${a.last_upload.slice(0, 10)}`);
811
- console.log();
748
+ if (a.last_upload) lines.push(` Last upload: ${a.last_upload.slice(0, 10)}`);
749
+ lines.push("");
812
750
  }
751
+ return lines.join("\n");
813
752
  }
814
753
  }
815
754
 
816
- // -- Notification Delivery ----------------------------------------------------
817
-
818
- /**
819
- * Send notification via gateway.
820
- * To use a different system (email, Discord webhook, Pushover, etc.),
821
- * replace the body of this function with your preferred delivery mechanism.
822
- */
823
- async function sendNotification(message: string): Promise<boolean> {
824
- if (!NOTIFICATIONS_ENABLED) return false;
825
-
826
- let token: string;
827
- let port: number;
828
- try {
829
- const cfg = JSON.parse(fs.readFileSync(GATEWAY_CONFIG, "utf-8"));
830
- token = cfg.gateway.auth.token;
831
- port = cfg.gateway.port ?? GATEWAY_PORT_DEFAULT;
832
- } catch (e) {
833
- console.log(`Gateway config error: ${e}`);
834
- return false;
835
- }
836
-
837
- try {
838
- const resp = await fetch(`http://127.0.0.1:${port}${GATEWAY_ENDPOINT}`, {
839
- method: "POST",
840
- headers: {
841
- "Content-Type": "application/json",
842
- Authorization: `Bearer ${token}`,
843
- },
844
- body: JSON.stringify({
845
- tool: GATEWAY_TOOL_NAME,
846
- args: { sessionKey: GATEWAY_SESSION_KEY, message },
847
- }),
848
- signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
849
- });
850
- return resp.ok;
851
- } catch (e) {
852
- console.log(`Notification failed: ${e}`);
853
- return false;
854
- }
855
- }
856
-
857
- // -- Commands -----------------------------------------------------------------
858
-
859
- async function runFullCheck(
860
- api: SoundCloudAPI,
861
- config: Config
862
- ): Promise<[string[], ReleaseNotification[]]> {
863
- const account = new AccountWatcher(api, config);
864
- const tracker = new ArtistTracker(api);
865
- return [await account.check(), await tracker.checkReleases()];
866
- }
867
-
868
- async function main(): Promise<void> {
869
- const config = Config.load();
755
+ // =============================================================================
756
+ // EXPORTED FACADE
757
+ // =============================================================================
870
758
 
871
- if (!config.clientId) {
872
- console.log(`Error: No SOUNDCLOUD_CLIENT_ID found in ${CONFIG_FILE}`);
873
- console.log(`\nCreate the config file with:`);
874
- console.log(` mkdir -p ${path.dirname(CONFIG_FILE)}`);
875
- console.log(` cat > ${CONFIG_FILE} << 'EOF'`);
876
- console.log(` SOUNDCLOUD_CLIENT_ID=your_id`);
877
- console.log(` SOUNDCLOUD_CLIENT_SECRET=your_secret`);
878
- console.log(` MY_USERNAME=${MY_USERNAME}`);
879
- console.log(` EOF`);
880
- return;
759
+ export class SoundCloudWatcher {
760
+ private config: Config;
761
+ private api: SoundCloudAPI;
762
+ private myTracksLimit: number;
763
+ private dormantDays: number;
764
+ private log: (...args: any[]) => void;
765
+
766
+ constructor(opts: SoundCloudWatcherConfig) {
767
+ this.log = opts.logger ?? console.log;
768
+ this.myTracksLimit = opts.myTracksLimit ?? 10;
769
+ this.dormantDays = opts.dormantDays ?? 90;
770
+ this.config = new Config(opts.clientId, opts.clientSecret, opts.username);
771
+ this.api = new SoundCloudAPI(this.config, this.log);
881
772
  }
882
773
 
883
- const api = new SoundCloudAPI(config);
884
-
885
- if (!config.accessToken) {
886
- if (!(await api.refreshToken())) {
887
- console.log("Failed to get access token");
888
- return;
774
+ private async ensureToken(): Promise<string | null> {
775
+ if (this.config.accessToken) return null;
776
+ if (!(await this.api.refreshToken())) {
777
+ return "Failed to get access token. Check your clientId and clientSecret.";
889
778
  }
779
+ return null;
890
780
  }
891
781
 
892
- const args = process.argv.slice(2);
893
- const cmd = args[0];
782
+ async status(): Promise<string> {
783
+ const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
784
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
894
785
 
895
- if (!cmd) {
896
- console.log("SoundCloud Watcher");
897
- console.log();
898
- console.log("Commands:");
899
- console.log(" status Show current tracking status");
900
- console.log(" check Run full check with verbose output");
901
- console.log(" cron Silent mode - only sends notifications on updates");
902
- console.log(" add <user> Add artist(s) to track");
903
- console.log(" remove <user> Remove artist from tracking");
904
- console.log(" list List all tracked artists");
905
- return;
906
- }
907
-
908
- if (cmd === "status") {
909
- const account = new AccountWatcher(api, config);
910
- const tracker = new ArtistTracker(api);
911
-
912
- console.log("=== SoundCloud Watcher Status ===\n");
913
- console.log(`Config: ${CONFIG_FILE}`);
914
- console.log(
915
- config.accessToken
916
- ? `Token: ...${config.accessToken.slice(-8)}`
786
+ const lines: string[] = [];
787
+ lines.push("=== SoundCloud Watcher Status ===\n");
788
+ lines.push(`Config: ${CONFIG_FILE}`);
789
+ lines.push(
790
+ this.config.accessToken
791
+ ? `Token: ...${this.config.accessToken.slice(-8)}`
917
792
  : "Token: None"
918
793
  );
919
794
  if (account.data.my_account) {
920
- console.log(`Account: @${account.data.my_account.username}`);
921
- console.log(
795
+ lines.push(`Account: @${account.data.my_account.username}`);
796
+ lines.push(
922
797
  `Followers: ${account.data.follower_count || Object.keys(account.data.my_followers).length}`
923
798
  );
924
799
  }
925
800
  const total = Object.keys(tracker.data.artists).length;
926
801
  const dormant = Object.values(tracker.data.artists).filter(
927
- (a) => (daysSince(a.last_upload ?? "") ?? 0) > DORMANT_DAYS
802
+ (a) => (daysSince(a.last_upload ?? "") ?? 0) > this.dormantDays
928
803
  ).length;
929
- console.log(
804
+ lines.push(
930
805
  `Tracked artists: ${total} (${total - dormant} active, ${dormant} dormant)`
931
806
  );
932
- console.log(
933
- `Notifications: ${NOTIFICATIONS_ENABLED ? "gateway" : "stdout only"}`
934
- );
935
- console.log(`Last check: ${account.data.last_check ?? "Never"}`);
936
- } else if (cmd === "check") {
937
- console.log(`[${utcnow()}] Running full check...\n`);
807
+ lines.push(`Last check: ${account.data.last_check ?? "Never"}`);
808
+ return lines.join("\n");
809
+ }
810
+
811
+ async check(): Promise<string> {
812
+ const tokenErr = await this.ensureToken();
813
+ if (tokenErr) return tokenErr;
814
+
815
+ const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
816
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
817
+
818
+ const [accountNotifs, releases] = await Promise.all([
819
+ account.check(),
820
+ tracker.checkReleases(),
821
+ ]);
822
+
823
+ const lines: string[] = [];
824
+ lines.push(`[${utcnow()}] Full check complete\n`);
825
+
826
+ lines.push("--- Account ---");
827
+ for (const n of accountNotifs) lines.push(` ${n}`);
828
+ if (!accountNotifs.length) lines.push(" No updates");
829
+
830
+ lines.push("\n--- Artist Releases ---");
831
+ for (const r of releases) lines.push(` ${r.artist}: ${r.title}`);
832
+ if (!releases.length) lines.push(" No new releases");
938
833
 
939
- const [accountNotifs, releases] = await runFullCheck(api, config);
834
+ lines.push(`\nAPI calls: ${this.api.calls}`);
835
+ return lines.join("\n");
836
+ }
837
+
838
+ async addArtist(username: string): Promise<string> {
839
+ const tokenErr = await this.ensureToken();
840
+ if (tokenErr) return tokenErr;
841
+
842
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
843
+ return tracker.add(username);
844
+ }
845
+
846
+ async addArtists(usernames: string[]): Promise<string> {
847
+ const tokenErr = await this.ensureToken();
848
+ if (tokenErr) return tokenErr;
849
+
850
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
851
+ const results: string[] = [];
852
+ for (const username of usernames) {
853
+ results.push(await tracker.add(username));
854
+ }
855
+ return results.join("\n");
856
+ }
857
+
858
+ async removeArtist(username: string): Promise<string> {
859
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
860
+ return tracker.remove(username);
861
+ }
862
+
863
+ async listArtists(): Promise<string> {
864
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
865
+ return tracker.list();
866
+ }
940
867
 
941
- console.log("--- Account ---");
942
- for (const n of accountNotifs) console.log(` ${n}`);
943
- if (!accountNotifs.length) console.log(" No updates");
868
+ async runCron(): Promise<string | null> {
869
+ const tokenErr = await this.ensureToken();
870
+ if (tokenErr) {
871
+ this.log(tokenErr);
872
+ return null;
873
+ }
944
874
 
945
- console.log("\n--- Artist Releases ---");
946
- for (const r of releases) console.log(` ${r.artist}: ${r.title}`);
947
- if (!releases.length) console.log(" No new releases");
875
+ const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
876
+ const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
948
877
 
949
- console.log(`\nAPI calls: ${api.calls}`);
950
- } else if (cmd === "cron") {
951
- const [accountNotifs, releases] = await runFullCheck(api, config);
878
+ const [accountNotifs, releases] = await Promise.all([
879
+ account.check(),
880
+ tracker.checkReleases(),
881
+ ]);
952
882
 
953
883
  const lines: string[] = [];
954
884
  if (accountNotifs.length) {
@@ -966,33 +896,9 @@ async function main(): Promise<void> {
966
896
  }
967
897
 
968
898
  if (lines.length) {
969
- const message = "SoundCloud updates:\n\n" + lines.join("\n");
970
- if (NOTIFICATIONS_ENABLED) {
971
- await sendNotification(message);
972
- } else {
973
- console.log(message);
974
- }
899
+ return "SoundCloud updates:\n\n" + lines.join("\n");
975
900
  }
976
901
 
977
- console.log(`API calls: ${api.calls}`);
978
- } else if (cmd === "add" && args.length > 1) {
979
- const tracker = new ArtistTracker(api);
980
- for (const username of args.slice(1)) {
981
- console.log(await tracker.add(username));
982
- }
983
- } else if (cmd === "remove" && args.length > 1) {
984
- const tracker = new ArtistTracker(api);
985
- console.log(tracker.remove(args[1]));
986
- } else if (cmd === "list") {
987
- const tracker = new ArtistTracker(api);
988
- tracker.list();
989
- } else {
990
- console.log(`Unknown command: ${cmd}`);
991
- console.log("Run without arguments for usage info.");
902
+ return null;
992
903
  }
993
904
  }
994
-
995
- main().catch((e) => {
996
- console.error(`Fatal error: ${e}`);
997
- process.exit(1);
998
- });