@akilles/soundcloud-watcher 1.0.4 → 2.0.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/index.ts CHANGED
@@ -1,259 +1,183 @@
1
- import { spawn } from 'child_process';
1
+ import { SoundCloudWatcher } from './soundcloud_watcher';
2
+ import { readFileSync, existsSync } from 'fs';
2
3
  import { join } from 'path';
4
+ import { homedir } from 'os';
3
5
 
4
- interface SoundCloudConfig {
5
- enabled: boolean;
6
+ interface PluginConfig {
6
7
  clientId: string;
7
8
  clientSecret: string;
8
9
  username: string;
9
10
  checkIntervalHours: number;
10
11
  myTracksLimit: number;
11
12
  dormantDays: number;
12
- sessionKey?: string; // Where to send notifications (defaults to 'agent:main:main')
13
+ sessionKey?: string;
14
+ }
15
+
16
+ function loadConfig(): PluginConfig | null {
17
+ // Try loading from env file first
18
+ const envPath = join(homedir(), '.openclaw', 'secrets', 'soundcloud.env');
19
+ if (existsSync(envPath)) {
20
+ const content = readFileSync(envPath, 'utf-8');
21
+ const env: Record<string, string> = {};
22
+ for (const line of content.split('\n')) {
23
+ const trimmed = line.trim();
24
+ if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
25
+ const [key, ...rest] = trimmed.split('=');
26
+ env[key.trim()] = rest.join('=').trim();
27
+ }
28
+ }
29
+ if (env.SOUNDCLOUD_CLIENT_ID && env.SOUNDCLOUD_CLIENT_SECRET && env.MY_USERNAME) {
30
+ return {
31
+ clientId: env.SOUNDCLOUD_CLIENT_ID,
32
+ clientSecret: env.SOUNDCLOUD_CLIENT_SECRET,
33
+ username: env.MY_USERNAME,
34
+ checkIntervalHours: 6,
35
+ myTracksLimit: 10,
36
+ dormantDays: 90,
37
+ sessionKey: 'agent:main:main',
38
+ };
39
+ }
40
+ }
41
+ return null;
13
42
  }
14
43
 
15
44
  export default function register(api: any) {
16
45
  const logger = api.getLogger?.() || console;
17
- 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();
48
- });
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
- });
46
+ let watcher: SoundCloudWatcher | null = null;
47
+
48
+ function getWatcher(): SoundCloudWatcher | null {
49
+ if (watcher) return watcher;
50
+
51
+ const config = loadConfig();
52
+ if (!config) return null;
53
+
54
+ watcher = new SoundCloudWatcher({
55
+ clientId: config.clientId,
56
+ clientSecret: config.clientSecret,
57
+ username: config.username,
58
+ myTracksLimit: config.myTracksLimit,
59
+ dormantDays: config.dormantDays,
60
+ logger: (...args: any[]) => logger.debug?.(...args) || console.log(...args),
57
61
  });
62
+ return watcher;
58
63
  }
59
64
 
60
- /**
61
- * Run a check for SoundCloud updates
62
- */
63
- async function checkForUpdates(config: SoundCloudConfig, sessionKey?: string) {
64
- if (!config.enabled) {
65
- logger.debug('SoundCloud watcher is disabled');
66
- return;
67
- }
65
+ function getSetupMessage(): string {
66
+ const config = loadConfig();
67
+
68
+ if (config) {
69
+ return `# SoundCloud Watcher Setup
68
70
 
69
- try {
70
- logger.info('Running SoundCloud check...');
71
- const result = await runWatcherScript(['cron']);
71
+ Already configured!
72
72
 
73
- if (result.code !== 0) {
74
- logger.error('SoundCloud check failed:', result.stderr || result.stdout);
75
- return;
76
- }
73
+ - Username: ${config.username}
74
+ - Client ID: ${config.clientId.substring(0, 8)}...${config.clientId.slice(-4)}
75
+ - Check interval: ${config.checkIntervalHours} hours
77
76
 
78
- // The script outputs notifications to stdout in cron mode
79
- if (result.stdout.trim()) {
80
- logger.info('SoundCloud updates found:', result.stdout);
77
+ To update credentials, edit:
78
+ \`~/.openclaw/secrets/soundcloud.env\`
81
79
 
82
- // If a session key is provided, send the notification there
83
- if (sessionKey) {
84
- try {
85
- await api.tools.sessions_send({
86
- sessionKey,
87
- message: result.stdout.trim(),
88
- });
89
- } catch (err) {
90
- logger.error('Failed to send notification:', err);
91
- }
92
- }
93
- } else {
94
- logger.debug('No SoundCloud updates');
95
- }
96
- } catch (err) {
97
- logger.error('Error during SoundCloud check:', err);
80
+ Then restart: \`openclaw gateway restart\``;
98
81
  }
99
- }
82
+
83
+ return `# SoundCloud Watcher Setup
100
84
 
101
- /**
102
- * Start the periodic check loop
103
- */
104
- function startChecking(config: SoundCloudConfig, sessionKey?: string) {
105
- if (checkInterval) {
106
- clearInterval(checkInterval);
107
- }
85
+ Not configured yet.
108
86
 
109
- const intervalMs = config.checkIntervalHours * 60 * 60 * 1000;
87
+ ## Steps:
110
88
 
111
- // Run immediately on startup
112
- checkForUpdates(config, sessionKey).catch((err) => {
113
- logger.error('Initial SoundCloud check failed:', err);
114
- });
89
+ 1. Get credentials from https://soundcloud.com/you/apps
115
90
 
116
- // Then run periodically
117
- checkInterval = setInterval(() => {
118
- checkForUpdates(config, sessionKey).catch((err) => {
119
- logger.error('Periodic SoundCloud check failed:', err);
120
- });
121
- }, intervalMs);
91
+ 2. Create config file:
92
+ \`\`\`bash
93
+ mkdir -p ~/.openclaw/secrets
94
+ nano ~/.openclaw/secrets/soundcloud.env
95
+ \`\`\`
122
96
 
123
- logger.info(`SoundCloud watcher started (checking every ${config.checkIntervalHours}h)`);
124
- }
97
+ 3. Add your credentials:
98
+ \`\`\`
99
+ SOUNDCLOUD_CLIENT_ID=your_client_id
100
+ SOUNDCLOUD_CLIENT_SECRET=your_client_secret
101
+ MY_USERNAME=your_soundcloud_username
102
+ \`\`\`
125
103
 
126
- /**
127
- * Stop the periodic check loop
128
- */
129
- function stopChecking() {
130
- if (checkInterval) {
131
- clearInterval(checkInterval);
132
- checkInterval = null;
133
- logger.info('SoundCloud watcher stopped');
134
- }
104
+ 4. Restart: \`openclaw gateway restart\`
105
+
106
+ 5. Verify: \`/soundcloud-status\``;
135
107
  }
136
108
 
137
109
  // Register commands
138
110
  api.registerCommand({
139
111
  name: 'soundcloud-setup',
140
- description: 'Interactive setup for SoundCloud credentials',
141
- handler: async (ctx: any) => {
142
- const config = api.getConfig() as SoundCloudConfig;
143
-
144
- let message = '# SoundCloud Watcher Setup\n\n';
145
-
146
- if (config.clientId && config.clientSecret && config.username) {
147
- message += '✓ Already configured!\n\n';
148
- message += `- Username: ${config.username}\n`;
149
- message += `- Client ID: ${config.clientId.substring(0, 8)}...${config.clientId.slice(-4)}\n`;
150
- message += `- Check interval: ${config.checkIntervalHours} hours\n`;
151
- message += `- Session key: ${config.sessionKey || 'agent:main:main'}\n\n`;
152
- message += 'To update, edit `~/.openclaw/openclaw.json` under:\n';
153
- message += '`plugins.entries.soundcloud-watcher.config`\n\n';
154
- message += 'Then restart: `openclaw gateway restart`';
155
- } else {
156
- message += 'Warning: Not configured yet\n\n';
157
- message += '## Steps:\n\n';
158
- message += '1. Get credentials from https://soundcloud.com/you/apps\n';
159
- message += '2. Edit `~/.openclaw/openclaw.json`:\n\n';
160
- message += '```json\n';
161
- message += '{\n';
162
- message += ' "plugins": {\n';
163
- message += ' "entries": {\n';
164
- message += ' "soundcloud-watcher": {\n';
165
- message += ' "enabled": true,\n';
166
- message += ' "config": {\n';
167
- message += ' "clientId": "YOUR_CLIENT_ID",\n';
168
- message += ' "clientSecret": "YOUR_CLIENT_SECRET",\n';
169
- message += ' "username": "your_soundcloud_username",\n';
170
- message += ' "checkIntervalHours": 6\n';
171
- message += ' }\n';
172
- message += ' }\n';
173
- message += ' }\n';
174
- message += ' }\n';
175
- message += '}\n';
176
- message += '```\n\n';
177
- message += '3. Restart: `openclaw gateway restart`\n';
178
- message += '4. Run: `/soundcloud-status` to verify';
179
- }
180
-
181
- return { text: message };
112
+ description: 'Show SoundCloud watcher setup instructions',
113
+ handler: async () => {
114
+ return { text: getSetupMessage() };
182
115
  },
183
116
  });
184
117
 
185
118
  api.registerCommand({
186
119
  name: 'soundcloud-status',
187
120
  description: 'Show SoundCloud watcher status',
188
- handler: async (ctx: any) => {
189
- const result = await runWatcherScript(['status']);
190
- return { text: result.stdout || result.stderr };
121
+ handler: async () => {
122
+ const w = getWatcher();
123
+ if (!w) return { text: 'Not configured. Run /soundcloud-setup for instructions.' };
124
+ const result = await w.status();
125
+ return { text: result };
191
126
  },
192
127
  });
193
128
 
194
129
  api.registerCommand({
195
130
  name: 'soundcloud-check',
196
131
  description: 'Run an immediate SoundCloud check',
197
- handler: async (ctx: any) => {
198
- const result = await runWatcherScript(['check']);
199
- return { text: result.stdout || result.stderr };
132
+ handler: async () => {
133
+ const w = getWatcher();
134
+ if (!w) return { text: 'Not configured. Run /soundcloud-setup for instructions.' };
135
+ const result = await w.check();
136
+ return { text: result };
200
137
  },
201
138
  });
202
139
 
203
140
  api.registerCommand({
204
141
  name: 'soundcloud-add',
205
142
  description: 'Add artist(s) to track',
206
- handler: async (ctx: any, ...usernames: string[]) => {
207
- if (usernames.length === 0) {
208
- return { text: 'Usage: /soundcloud-add <username> [username2] ...' };
209
- }
210
- const result = await runWatcherScript(['add', ...usernames]);
211
- return { text: result.stdout || result.stderr };
143
+ handler: async (ctx: any) => {
144
+ const w = getWatcher();
145
+ if (!w) return { text: 'Not configured. Run /soundcloud-setup for instructions.' };
146
+
147
+ const args = ctx.args?.trim();
148
+ if (!args) return { text: 'Usage: /soundcloud-add <username> [username2] ...' };
149
+
150
+ const usernames = args.split(/\s+/).filter(Boolean);
151
+ const result = await w.addArtists(usernames);
152
+ return { text: result };
212
153
  },
213
154
  });
214
155
 
215
156
  api.registerCommand({
216
157
  name: 'soundcloud-remove',
217
158
  description: 'Remove an artist from tracking',
218
- handler: async (ctx: any, username?: string) => {
219
- if (!username) {
220
- return { text: 'Usage: /soundcloud-remove <username>' };
221
- }
222
- const result = await runWatcherScript(['remove', username]);
223
- return { text: result.stdout || result.stderr };
159
+ handler: async (ctx: any) => {
160
+ const w = getWatcher();
161
+ if (!w) return { text: 'Not configured. Run /soundcloud-setup for instructions.' };
162
+
163
+ const username = ctx.args?.trim();
164
+ if (!username) return { text: 'Usage: /soundcloud-remove <username>' };
165
+
166
+ const result = await w.removeArtist(username);
167
+ return { text: result };
224
168
  },
225
169
  });
226
170
 
227
171
  api.registerCommand({
228
172
  name: 'soundcloud-list',
229
173
  description: 'List all tracked artists',
230
- handler: async (ctx: any) => {
231
- const result = await runWatcherScript(['list']);
232
- return { text: result.stdout || result.stderr };
174
+ handler: async () => {
175
+ const w = getWatcher();
176
+ if (!w) return { text: 'Not configured. Run /soundcloud-setup for instructions.' };
177
+ const result = await w.listArtists();
178
+ return { text: result };
233
179
  },
234
180
  });
235
181
 
236
- // Handle config changes
237
- api.onConfigChange?.((config: SoundCloudConfig) => {
238
- if (config.enabled) {
239
- const sessionKey = config.sessionKey || 'agent:main:main';
240
- startChecking(config, sessionKey);
241
- } else {
242
- stopChecking();
243
- }
244
- });
245
-
246
- // Initialize on load
247
- const initialConfig = api.getConfig() as SoundCloudConfig;
248
- if (initialConfig.enabled) {
249
- const sessionKey = initialConfig.sessionKey || 'agent:main:main';
250
- startChecking(initialConfig, sessionKey);
251
- }
252
-
253
- // Cleanup on unload
254
- api.onUnload?.(() => {
255
- stopChecking();
256
- });
257
-
258
- logger.info('SoundCloud Watcher plugin loaded');
182
+ logger.info?.('SoundCloud Watcher plugin loaded') || console.log('SoundCloud Watcher plugin loaded');
259
183
  }
@@ -2,51 +2,5 @@
2
2
  "id": "soundcloud-watcher",
3
3
  "name": "SoundCloud Watcher",
4
4
  "description": "Monitor your SoundCloud account and track artist releases",
5
- "version": "1.0.0",
6
- "configSchema": {
7
- "type": "object",
8
- "additionalProperties": false,
9
- "properties": {
10
- "enabled": {
11
- "type": "boolean",
12
- "default": true,
13
- "description": "Enable/disable the SoundCloud watcher"
14
- },
15
- "clientId": {
16
- "type": "string",
17
- "description": "SoundCloud API Client ID"
18
- },
19
- "clientSecret": {
20
- "type": "string",
21
- "description": "SoundCloud API Client Secret"
22
- },
23
- "username": {
24
- "type": "string",
25
- "description": "Your SoundCloud username (URL slug)"
26
- },
27
- "checkIntervalHours": {
28
- "type": "number",
29
- "default": 6,
30
- "minimum": 1,
31
- "maximum": 24,
32
- "description": "How often to check for updates (in hours)"
33
- },
34
- "myTracksLimit": {
35
- "type": "number",
36
- "default": 10,
37
- "description": "Number of your tracks to monitor"
38
- },
39
- "dormantDays": {
40
- "type": "number",
41
- "default": 90,
42
- "description": "Days before an artist is considered dormant"
43
- },
44
- "sessionKey": {
45
- "type": "string",
46
- "default": "agent:main:main",
47
- "description": "OpenClaw session key for notifications (e.g., 'agent:main:main', 'telegram:chat123', etc.)"
48
- }
49
- },
50
- "required": []
51
- }
5
+ "version": "2.0.0"
52
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akilles/soundcloud-watcher",
3
- "version": "1.0.4",
3
+ "version": "2.0.1",
4
4
  "description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
@@ -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
- });