@akilles/soundcloud-watcher 1.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 ADDED
@@ -0,0 +1,259 @@
1
+ import { spawn } from 'child_process';
2
+ import { join } from 'path';
3
+
4
+ interface SoundCloudConfig {
5
+ enabled: boolean;
6
+ clientId: string;
7
+ clientSecret: string;
8
+ username: string;
9
+ checkIntervalHours: number;
10
+ myTracksLimit: number;
11
+ dormantDays: number;
12
+ sessionKey?: string; // Where to send notifications (defaults to 'agent:main:main')
13
+ }
14
+
15
+ export default function register(api: any) {
16
+ 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
+ });
57
+ });
58
+ }
59
+
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
+ }
68
+
69
+ try {
70
+ 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
+ }
77
+
78
+ // The script outputs notifications to stdout in cron mode
79
+ if (result.stdout.trim()) {
80
+ logger.info('SoundCloud updates found:', result.stdout);
81
+
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);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Start the periodic check loop
103
+ */
104
+ function startChecking(config: SoundCloudConfig, sessionKey?: string) {
105
+ if (checkInterval) {
106
+ clearInterval(checkInterval);
107
+ }
108
+
109
+ const intervalMs = config.checkIntervalHours * 60 * 60 * 1000;
110
+
111
+ // Run immediately on startup
112
+ checkForUpdates(config, sessionKey).catch((err) => {
113
+ logger.error('Initial SoundCloud check failed:', err);
114
+ });
115
+
116
+ // Then run periodically
117
+ checkInterval = setInterval(() => {
118
+ checkForUpdates(config, sessionKey).catch((err) => {
119
+ logger.error('Periodic SoundCloud check failed:', err);
120
+ });
121
+ }, intervalMs);
122
+
123
+ logger.info(`SoundCloud watcher started (checking every ${config.checkIntervalHours}h)`);
124
+ }
125
+
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
+ }
135
+ }
136
+
137
+ // Register commands
138
+ api.registerCommand({
139
+ 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 };
182
+ },
183
+ });
184
+
185
+ api.registerCommand({
186
+ name: 'soundcloud-status',
187
+ description: 'Show SoundCloud watcher status',
188
+ handler: async (ctx: any) => {
189
+ const result = await runWatcherScript(['status']);
190
+ return { text: result.stdout || result.stderr };
191
+ },
192
+ });
193
+
194
+ api.registerCommand({
195
+ name: 'soundcloud-check',
196
+ description: 'Run an immediate SoundCloud check',
197
+ handler: async (ctx: any) => {
198
+ const result = await runWatcherScript(['check']);
199
+ return { text: result.stdout || result.stderr };
200
+ },
201
+ });
202
+
203
+ api.registerCommand({
204
+ name: 'soundcloud-add',
205
+ 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 };
212
+ },
213
+ });
214
+
215
+ api.registerCommand({
216
+ name: 'soundcloud-remove',
217
+ 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 };
224
+ },
225
+ });
226
+
227
+ api.registerCommand({
228
+ name: 'soundcloud-list',
229
+ description: 'List all tracked artists',
230
+ handler: async (ctx: any) => {
231
+ const result = await runWatcherScript(['list']);
232
+ return { text: result.stdout || result.stderr };
233
+ },
234
+ });
235
+
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');
259
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "soundcloud-watcher",
3
+ "name": "SoundCloud Watcher",
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": ["clientId", "clientSecret", "username"]
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@akilles/soundcloud-watcher",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
5
+ "main": "index.ts",
6
+ "openclaw.extensions": [
7
+ "index.ts"
8
+ ],
9
+ "scripts": {
10
+ "preinstall": "echo 'Installing SoundCloud Watcher dependencies...'",
11
+ "postinstall": "echo '✓ SoundCloud Watcher installed! Run /soundcloud-setup to configure.'",
12
+ "test": "echo \"No tests yet\""
13
+ },
14
+ "keywords": [
15
+ "soundcloud",
16
+ "openclaw",
17
+ "plugin",
18
+ "music",
19
+ "monitoring",
20
+ "typescript"
21
+ ],
22
+ "author": "Akilles William Lndstedt",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/wlinds/openclaw-soundcloud-watcher.git",
27
+ "directory": "openclaw-soundcloud-watcher"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/wlinds/openclaw-soundcloud-watcher/issues"
31
+ },
32
+ "homepage": "https://github.com/wlinds/openclaw-soundcloud-watcher#readme",
33
+ "files": [
34
+ "index.ts",
35
+ "soundcloud_watcher.ts",
36
+ "openclaw.plugin.json",
37
+ "README.md"
38
+ ],
39
+ "dependencies": {
40
+ "tsx": "^4.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@types/node": "^22.0.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=22.0.0"
47
+ }
48
+ }
@@ -0,0 +1,998 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * SoundCloud Watcher: Cron script for tracking your SoundCloud account
4
+ * and getting notified about new releases from artists you care about.
5
+ *
6
+ * Features:
7
+ * - Follower change detection (new/lost followers by name)
8
+ * - Track engagement tracking (who liked, repost counts)
9
+ * - New release detection from a curated artist list
10
+ * - Dormant artist throttling (skip inactive artists to save API calls)
11
+ * - Rate limit backoff (exponential backoff on 429s)
12
+ * - Single cron entry runs everything
13
+ *
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)
27
+ */
28
+
29
+ import * as fs from "fs";
30
+ import * as path from "path";
31
+ import * as os from "os";
32
+
33
+ // =============================================================================
34
+ // CONFIGURATION - edit these to match your setup
35
+ // =============================================================================
36
+
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
+ const OPENCLAW_DIR = path.join(os.homedir(), ".openclaw");
42
+ const CONFIG_FILE = path.join(OPENCLAW_DIR, "secrets", "soundcloud.env");
43
+ const ACCOUNT_DATA = path.join(OPENCLAW_DIR, "data", "soundcloud_tracking.json");
44
+ const ARTISTS_DATA = path.join(OPENCLAW_DIR, "data", "artists.json");
45
+ const BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_backoff.json");
46
+
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
+ // =============================================================================
57
+ // TUNING - adjust these to balance API usage vs responsiveness
58
+ // =============================================================================
59
+
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
+ const ARTIST_TRACKS_LIMIT = 5;
65
+
66
+ /** How many tracks to fetch when first adding an artist (seeds known tracks) */
67
+ 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
+ const DORMANT_CHECK_INTERVAL_DAYS = 7;
74
+
75
+ /** Max stored track IDs per artist (older ones pruned to save disk/memory) */
76
+ const MAX_KNOWN_TRACKS = 50;
77
+
78
+ /** Max likers to fetch per track */
79
+ const MAX_LIKERS_PER_TRACK = 50;
80
+
81
+ /** Followers pagination page size (SoundCloud max is 200) */
82
+ const FOLLOWERS_PAGE_SIZE = 200;
83
+
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
87
+
88
+ // --- Timeouts ---
89
+ const API_TIMEOUT_MS = 15_000;
90
+ const GATEWAY_TIMEOUT_MS = 60_000;
91
+
92
+ // =============================================================================
93
+ // INTERNALS - you probably don't need to change anything below
94
+ // =============================================================================
95
+
96
+ const API_BASE = "https://api.soundcloud.com";
97
+
98
+ // -- Types --------------------------------------------------------------------
99
+
100
+ interface UserInfo {
101
+ username: string;
102
+ display_name: string;
103
+ }
104
+
105
+ interface TrackStats {
106
+ track_id: number;
107
+ title: string;
108
+ plays: number;
109
+ likes: number;
110
+ reposts: number;
111
+ likers: Record<string, UserInfo>;
112
+ }
113
+
114
+ interface ArtistData {
115
+ username: string;
116
+ display_name: string;
117
+ user_id: number;
118
+ permalink_url: string;
119
+ followers: number;
120
+ track_count: number;
121
+ total_plays: number;
122
+ genres: string[];
123
+ last_upload: string | null;
124
+ known_track_ids: number[];
125
+ added_at: string;
126
+ last_updated: string;
127
+ last_checked?: string;
128
+ }
129
+
130
+ interface AccountState {
131
+ my_account: { user_id: number; username: string } | null;
132
+ my_followers: Record<string, UserInfo>;
133
+ follower_count: number;
134
+ track_stats: TrackStats[];
135
+ last_check: string | null;
136
+ }
137
+
138
+ interface ArtistsState {
139
+ artists: Record<string, ArtistData>;
140
+ updated_at: string | null;
141
+ }
142
+
143
+ interface ReleaseNotification {
144
+ artist: string;
145
+ title: string;
146
+ url: string;
147
+ duration: string;
148
+ genre: string | null;
149
+ }
150
+
151
+ // -- Helpers ------------------------------------------------------------------
152
+
153
+ function utcnow(): string {
154
+ return new Date().toISOString();
155
+ }
156
+
157
+ function daysSince(isoDate: string): number | null {
158
+ const ms = Date.now() - parseTimestamp(isoDate);
159
+ return isNaN(ms) ? null : Math.floor(ms / 86_400_000);
160
+ }
161
+
162
+ /**
163
+ * Parse SoundCloud timestamps into Unix ms.
164
+ * SoundCloud returns dates like: "2026/01/22 16:22:27 +0000"
165
+ */
166
+ function parseTimestamp(ts: string | null | undefined): number {
167
+ if (!ts) return NaN;
168
+ 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
171
+ cleaned = cleaned.replace(" +0000", "+00:00").replace("Z", "+00:00");
172
+ const d = new Date(cleaned);
173
+ return d.getTime();
174
+ } catch {
175
+ console.log(`Warning: Could not parse timestamp '${ts}'`);
176
+ return NaN;
177
+ }
178
+ }
179
+
180
+ function ensureDir(filepath: string): void {
181
+ const dir = path.dirname(filepath);
182
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
183
+ }
184
+
185
+ function readJson<T>(filepath: string, fallback: T): T {
186
+ try {
187
+ if (fs.existsSync(filepath)) {
188
+ return JSON.parse(fs.readFileSync(filepath, "utf-8"));
189
+ }
190
+ } catch {
191
+ /* corrupted file, use fallback */
192
+ }
193
+ return fallback;
194
+ }
195
+
196
+ function writeJson(filepath: string, data: unknown): void {
197
+ ensureDir(filepath);
198
+ fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + "\n");
199
+ }
200
+
201
+ function num(val: unknown): number {
202
+ return (typeof val === "number" ? val : 0) || 0;
203
+ }
204
+
205
+ // -- Config -------------------------------------------------------------------
206
+
207
+ class Config {
208
+ clientId = "";
209
+ clientSecret = "";
210
+ 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;
226
+ }
227
+ return cfg;
228
+ }
229
+
230
+ saveToken(token: string): void {
231
+ this.accessToken = token;
232
+ ensureDir(CONFIG_FILE);
233
+ const lines = fs.existsSync(CONFIG_FILE)
234
+ ? fs.readFileSync(CONFIG_FILE, "utf-8").split("\n")
235
+ : [];
236
+ let found = false;
237
+ const newLines = lines.map((line) => {
238
+ if (line.startsWith("SOUNDCLOUD_ACCESS_TOKEN=")) {
239
+ found = true;
240
+ return `SOUNDCLOUD_ACCESS_TOKEN=${token}`;
241
+ }
242
+ return line;
243
+ });
244
+ if (!found) newLines.push(`SOUNDCLOUD_ACCESS_TOKEN=${token}`);
245
+ fs.writeFileSync(CONFIG_FILE, newLines.join("\n") + "\n");
246
+ }
247
+ }
248
+
249
+ // -- SoundCloud API client ----------------------------------------------------
250
+
251
+ class SoundCloudAPI {
252
+ calls = 0;
253
+
254
+ constructor(private config: Config) {}
255
+
256
+ private checkBackoff(): number | null {
257
+ const data = readJson<{ last_fail?: number; fail_count?: number }>(
258
+ BACKOFF_FILE,
259
+ {}
260
+ );
261
+ if (!data.last_fail) return null;
262
+ const elapsed = Date.now() / 1000 - data.last_fail;
263
+ const backoff = Math.min(
264
+ BACKOFF_BASE_SECONDS * 2 ** (data.fail_count ?? 0),
265
+ BACKOFF_MAX_SECONDS
266
+ );
267
+ return elapsed < backoff ? Math.floor(backoff - elapsed) : null;
268
+ }
269
+
270
+ private setBackoff(): void {
271
+ try {
272
+ const data = readJson<{ fail_count?: number }>(BACKOFF_FILE, {});
273
+ writeJson(BACKOFF_FILE, {
274
+ fail_count: (data.fail_count ?? 0) + 1,
275
+ last_fail: Date.now() / 1000,
276
+ });
277
+ } catch {
278
+ /* best effort */
279
+ }
280
+ }
281
+
282
+ private clearBackoff(): void {
283
+ if (fs.existsSync(BACKOFF_FILE)) fs.unlinkSync(BACKOFF_FILE);
284
+ }
285
+
286
+ async refreshToken(): Promise<boolean> {
287
+ if (!this.config.clientId || !this.config.clientSecret) return false;
288
+
289
+ const remaining = this.checkBackoff();
290
+ if (remaining) {
291
+ console.log(`Token refresh in backoff (${remaining}s remaining)`);
292
+ return false;
293
+ }
294
+
295
+ try {
296
+ const body = new URLSearchParams({
297
+ grant_type: "client_credentials",
298
+ client_id: this.config.clientId,
299
+ client_secret: this.config.clientSecret,
300
+ });
301
+ const resp = await fetch(`${API_BASE}/oauth2/token`, {
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
304
+ body: body.toString(),
305
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
306
+ });
307
+ if (resp.status === 429) {
308
+ this.setBackoff();
309
+ console.log("Token refresh rate limited (429)");
310
+ return false;
311
+ }
312
+ if (!resp.ok) {
313
+ console.log(`Token refresh failed: ${resp.status}`);
314
+ return false;
315
+ }
316
+ const result = (await resp.json()) as { access_token: string };
317
+ this.config.saveToken(result.access_token);
318
+ this.clearBackoff();
319
+ console.log("Token refreshed");
320
+ return true;
321
+ } catch (e) {
322
+ console.log(`Token refresh failed: ${e}`);
323
+ return false;
324
+ }
325
+ }
326
+
327
+ /** Make authenticated GET request. Accepts relative (/users/...) or full URLs. */
328
+ async get(
329
+ url: string,
330
+ params?: Record<string, string | number>,
331
+ retry = true
332
+ ): Promise<Record<string, any> | null> {
333
+ this.calls++;
334
+
335
+ let fullUrl: string;
336
+ if (url.startsWith("/")) fullUrl = `${API_BASE}${url}`;
337
+ else if (url.startsWith("http")) fullUrl = url;
338
+ else fullUrl = `${API_BASE}/${url}`;
339
+
340
+ if (params) {
341
+ const sep = fullUrl.includes("?") ? "&" : "?";
342
+ const query = new URLSearchParams(
343
+ Object.fromEntries(
344
+ Object.entries(params).map(([k, v]) => [k, String(v)])
345
+ )
346
+ ).toString();
347
+ fullUrl = `${fullUrl}${sep}${query}`;
348
+ }
349
+
350
+ const headers: Record<string, string> = {};
351
+ if (this.config.accessToken) {
352
+ headers["Authorization"] = `OAuth ${this.config.accessToken}`;
353
+ }
354
+
355
+ try {
356
+ const resp = await fetch(fullUrl, {
357
+ headers,
358
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
359
+ });
360
+
361
+ if (resp.status === 401 && retry) {
362
+ if (await this.refreshToken()) {
363
+ return this.get(url, params, false);
364
+ }
365
+ }
366
+ if (!resp.ok) {
367
+ console.log(`API error ${resp.status}: ${fullUrl}`);
368
+ return null;
369
+ }
370
+ return (await resp.json()) as Record<string, any>;
371
+ } catch (e) {
372
+ console.log(`API error: ${e}`);
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /** Resolve username to user object. */
378
+ resolve(username: string) {
379
+ return this.get("/resolve", {
380
+ url: `https://soundcloud.com/${username}`,
381
+ });
382
+ }
383
+
384
+ /** Get user profile by ID (includes followers_count). */
385
+ getUser(userId: number) {
386
+ return this.get(`/users/${userId}`);
387
+ }
388
+
389
+ /** Get a user's tracks. Response includes play/like/repost counts per track. */
390
+ async getTracks(userId: number, limit = 20): Promise<Record<string, any>[]> {
391
+ const data = await this.get(`/users/${userId}/tracks`, {
392
+ limit,
393
+ linked_partitioning: 1,
394
+ });
395
+ if (!data) return [];
396
+ const collection = data.collection ?? data;
397
+ return Array.isArray(collection) ? collection : [];
398
+ }
399
+
400
+ /** Get users who liked a specific track. */
401
+ async getTrackLikers(
402
+ trackId: number,
403
+ limit = MAX_LIKERS_PER_TRACK
404
+ ): Promise<Record<string, UserInfo>> {
405
+ const data = await this.get(`/tracks/${trackId}/favoriters`, {
406
+ limit,
407
+ linked_partitioning: 1,
408
+ });
409
+ if (!data) return {};
410
+
411
+ const likers: Record<string, UserInfo> = {};
412
+ const collection = data.collection ?? data;
413
+ if (Array.isArray(collection)) {
414
+ for (const u of collection) {
415
+ if (u && typeof u === "object" && "id" in u) {
416
+ likers[String(u.id)] = {
417
+ username: u.permalink ?? u.username ?? "unknown",
418
+ display_name: u.full_name ?? u.username ?? "unknown",
419
+ };
420
+ }
421
+ }
422
+ }
423
+ return likers;
424
+ }
425
+
426
+ /** Paginate through all followers. Expensive - only call when follower count changes. */
427
+ async getFollowersPaginated(
428
+ userId: number
429
+ ): Promise<Record<string, UserInfo>> {
430
+ const followers: Record<string, UserInfo> = {};
431
+ let nextUrl: string | null = `/users/${userId}/followers`;
432
+ let params: Record<string, string | number> | undefined = {
433
+ limit: FOLLOWERS_PAGE_SIZE,
434
+ linked_partitioning: 1,
435
+ };
436
+
437
+ while (nextUrl) {
438
+ const data = await this.get(nextUrl, params);
439
+ if (!data) break;
440
+
441
+ for (const f of data.collection ?? []) {
442
+ if (f && typeof f === "object" && "id" in f) {
443
+ followers[String(f.id)] = {
444
+ username: f.permalink ?? f.username ?? "unknown",
445
+ display_name: f.full_name ?? f.username ?? "unknown",
446
+ };
447
+ }
448
+ }
449
+
450
+ const nextHref = data.next_href;
451
+ if (nextHref && nextHref !== nextUrl) {
452
+ nextUrl = nextHref; // Full URL with cursor params included
453
+ params = undefined;
454
+ } else {
455
+ break;
456
+ }
457
+ }
458
+ return followers;
459
+ }
460
+ }
461
+
462
+ // -- Account Watcher ----------------------------------------------------------
463
+
464
+ class AccountWatcher {
465
+ data: AccountState;
466
+
467
+ constructor(
468
+ private api: SoundCloudAPI,
469
+ private config: Config
470
+ ) {
471
+ const defaults: AccountState = {
472
+ my_account: null,
473
+ my_followers: {},
474
+ follower_count: 0,
475
+ track_stats: [],
476
+ last_check: null,
477
+ };
478
+ const loaded = readJson<Partial<AccountState>>(ACCOUNT_DATA, {});
479
+ this.data = { ...defaults, ...loaded };
480
+ }
481
+
482
+ private save(): void {
483
+ this.data.last_check = utcnow();
484
+ writeJson(ACCOUNT_DATA, this.data);
485
+ }
486
+
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
+ async check(): Promise<string[]> {
494
+ const notifications: string[] = [];
495
+
496
+ // Resolve account on first run
497
+ if (!this.data.my_account) {
498
+ const user = await this.api.resolve(this.config.myUsername);
499
+ if (!user) return ["Failed to resolve SoundCloud user"];
500
+ this.data.my_account = {
501
+ user_id: user.id,
502
+ username: user.permalink ?? this.config.myUsername,
503
+ };
504
+ }
505
+
506
+ const userId = this.data.my_account.user_id;
507
+
508
+ // Fetch profile to check follower count (1 API call)
509
+ const profile = await this.api.getUser(userId);
510
+ if (!profile) {
511
+ console.log("Failed to fetch profile, skipping account check");
512
+ return notifications; // Don't save - preserve previous state
513
+ }
514
+
515
+ const currentCount = num(profile.followers_count);
516
+ const storedCount = this.data.follower_count;
517
+
518
+ // Only paginate full follower list if the count actually changed
519
+ if (currentCount !== storedCount || !Object.keys(this.data.my_followers).length) {
520
+ console.log(
521
+ `Follower count changed: ${storedCount} -> ${currentCount}, fetching list...`
522
+ );
523
+ const currentFollowers = await this.api.getFollowersPaginated(userId);
524
+
525
+ if (!Object.keys(currentFollowers).length && storedCount > 0) {
526
+ console.log("API returned empty followers, skipping comparison");
527
+ } else {
528
+ const stored = this.data.my_followers;
529
+
530
+ // Skip diff on first run (everything would show as "new")
531
+ if (Object.keys(stored).length) {
532
+ const newFollowers = Object.entries(currentFollowers)
533
+ .filter(([uid]) => !stored[uid])
534
+ .map(([, f]) => f.display_name);
535
+ const lostFollowers = Object.entries(stored)
536
+ .filter(([uid]) => !currentFollowers[uid])
537
+ .map(([, f]) => f.display_name);
538
+
539
+ if (newFollowers.length) {
540
+ let names = newFollowers.slice(0, 3).join(", ");
541
+ if (newFollowers.length > 3) names += ` +${newFollowers.length - 3} more`;
542
+ notifications.push(
543
+ `New follower${newFollowers.length > 1 ? "s" : ""}: **${names}**`
544
+ );
545
+ }
546
+ if (lostFollowers.length) {
547
+ const names = lostFollowers.slice(0, 3).join(", ");
548
+ notifications.push(
549
+ `Lost follower${lostFollowers.length > 1 ? "s" : ""}: ${names}`
550
+ );
551
+ }
552
+ }
553
+
554
+ this.data.my_followers = currentFollowers;
555
+ this.data.follower_count = currentCount;
556
+ }
557
+ } else {
558
+ console.log(`Follower count unchanged (${currentCount}), skipping pagination`);
559
+ }
560
+
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);
563
+ if (tracks.length) {
564
+ const prevMap = new Map(this.data.track_stats.map((s) => [s.track_id, s]));
565
+ const newStats: TrackStats[] = [];
566
+
567
+ for (const t of tracks) {
568
+ const trackId: number = t.id;
569
+ const title: string = t.title ?? "Unknown";
570
+ const currentLikes = num(t.likes_count) || num(t.favoritings_count);
571
+ const currentReposts = num(t.reposts_count);
572
+
573
+ const stats: TrackStats = {
574
+ track_id: trackId,
575
+ title,
576
+ plays: num(t.playback_count),
577
+ likes: currentLikes,
578
+ reposts: currentReposts,
579
+ likers: {},
580
+ };
581
+
582
+ const prev = prevMap.get(trackId);
583
+ if (prev) {
584
+ const prevLikes = prev.likes;
585
+ const prevLikers = prev.likers ?? {};
586
+
587
+ // Only fetch liker list if like count changed (or never seeded)
588
+ const needsLikerFetch =
589
+ currentLikes !== prevLikes ||
590
+ (currentLikes > 0 && !Object.keys(prevLikers).length);
591
+
592
+ if (needsLikerFetch) {
593
+ const currentLikers = await this.api.getTrackLikers(trackId);
594
+ stats.likers = currentLikers;
595
+
596
+ const newLikerNames = Object.entries(currentLikers)
597
+ .filter(([uid]) => !prevLikers[uid])
598
+ .map(([, u]) => u.display_name || u.username);
599
+ const unlikerNames = Object.entries(prevLikers)
600
+ .filter(([uid]) => !currentLikers[uid])
601
+ .map(([, u]) => u.display_name || u.username);
602
+
603
+ if (newLikerNames.length) {
604
+ let names = newLikerNames.slice(0, 3).join(", ");
605
+ if (newLikerNames.length > 3)
606
+ names += ` +${newLikerNames.length - 3} more`;
607
+ notifications.push(`**${names}** liked '${title}'`);
608
+ }
609
+ if (unlikerNames.length) {
610
+ const names = unlikerNames.slice(0, 3).join(", ");
611
+ notifications.push(`${names} unliked '${title}'`);
612
+ }
613
+ } else {
614
+ // No change - carry forward previous liker data
615
+ stats.likers = prevLikers;
616
+ }
617
+
618
+ const newReposts = currentReposts - (prev.reposts ?? 0);
619
+ if (newReposts > 0) {
620
+ notifications.push(
621
+ `'${title}' got ${newReposts} repost${newReposts > 1 ? "s" : ""}!`
622
+ );
623
+ }
624
+ } else {
625
+ // First time seeing this track - seed likers without notifying
626
+ stats.likers = await this.api.getTrackLikers(trackId);
627
+ }
628
+
629
+ newStats.push(stats);
630
+ }
631
+
632
+ this.data.track_stats = newStats;
633
+ } else {
634
+ console.log("Failed to fetch tracks, keeping previous stats");
635
+ }
636
+
637
+ this.save();
638
+ return notifications;
639
+ }
640
+ }
641
+
642
+ // -- Artist Tracker -----------------------------------------------------------
643
+
644
+ class ArtistTracker {
645
+ data: ArtistsState;
646
+
647
+ constructor(private api: SoundCloudAPI) {
648
+ this.data = readJson<ArtistsState>(ARTISTS_DATA, {
649
+ artists: {},
650
+ updated_at: null,
651
+ });
652
+ }
653
+
654
+ private save(): void {
655
+ this.data.updated_at = utcnow();
656
+ writeJson(ARTISTS_DATA, this.data);
657
+ }
658
+
659
+ private isDormant(artist: ArtistData): boolean {
660
+ const days = daysSince(artist.last_upload ?? "");
661
+ return days !== null && days > DORMANT_DAYS;
662
+ }
663
+
664
+ private shouldSkip(artist: ArtistData): boolean {
665
+ if (!this.isDormant(artist)) return false;
666
+ const days = daysSince(artist.last_checked ?? "");
667
+ return days !== null && days < DORMANT_CHECK_INTERVAL_DAYS;
668
+ }
669
+
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
+ async checkReleases(): Promise<ReleaseNotification[]> {
675
+ const notifications: ReleaseNotification[] = [];
676
+ let checked = 0;
677
+ let skipped = 0;
678
+
679
+ for (const [username, artist] of Object.entries(this.data.artists)) {
680
+ if (this.shouldSkip(artist)) {
681
+ skipped++;
682
+ continue;
683
+ }
684
+
685
+ checked++;
686
+ if (!artist.user_id) continue;
687
+
688
+ const tracks = await this.api.getTracks(artist.user_id, ARTIST_TRACKS_LIMIT);
689
+ const knownIds = new Set(artist.known_track_ids ?? []);
690
+
691
+ this.data.artists[username].last_checked = utcnow();
692
+
693
+ for (const track of tracks) {
694
+ if (!knownIds.has(track.id)) {
695
+ const durationSec = Math.floor(num(track.duration) / 1000);
696
+ const min = Math.floor(durationSec / 60);
697
+ const sec = durationSec % 60;
698
+
699
+ notifications.push({
700
+ artist: artist.display_name ?? username,
701
+ title: track.title ?? "Unknown",
702
+ url: track.permalink_url ?? "",
703
+ duration: `${min}:${String(sec).padStart(2, "0")}`,
704
+ genre: track.genre ?? null,
705
+ });
706
+
707
+ if (!this.data.artists[username].known_track_ids) {
708
+ this.data.artists[username].known_track_ids = [];
709
+ }
710
+ this.data.artists[username].known_track_ids.push(track.id);
711
+
712
+ if (track.created_at) {
713
+ this.data.artists[username].last_upload = track.created_at;
714
+ }
715
+ }
716
+ }
717
+
718
+ // Prune old track IDs to prevent unbounded growth
719
+ const ids = this.data.artists[username].known_track_ids ?? [];
720
+ if (ids.length > MAX_KNOWN_TRACKS) {
721
+ this.data.artists[username].known_track_ids = ids.slice(-MAX_KNOWN_TRACKS);
722
+ }
723
+ }
724
+
725
+ const dormantCount = Object.values(this.data.artists).filter((a) =>
726
+ this.isDormant(a)
727
+ ).length;
728
+ console.log(
729
+ `Checked ${checked} artists, skipped ${skipped} dormant, ${dormantCount} total dormant`
730
+ );
731
+
732
+ this.save();
733
+ return notifications;
734
+ }
735
+
736
+ /** Add an artist to tracking. Seeds known tracks to avoid false notifications. */
737
+ async add(username: string): Promise<string> {
738
+ const user = await this.api.resolve(username);
739
+ if (!user) return `Could not find user: ${username}`;
740
+
741
+ const tracks = await this.api.getTracks(user.id, ARTIST_ADD_LIMIT);
742
+
743
+ const totalPlays = tracks.reduce(
744
+ (sum, t) => sum + num(t.playback_count),
745
+ 0
746
+ );
747
+
748
+ const genreCounts: Record<string, number> = {};
749
+ for (const t of tracks) {
750
+ const g = (t.genre ?? "").toLowerCase().trim();
751
+ if (g) genreCounts[g] = (genreCounts[g] ?? 0) + 1;
752
+ }
753
+ const topGenres = Object.entries(genreCounts)
754
+ .sort((a, b) => b[1] - a[1])
755
+ .slice(0, 3)
756
+ .map(([g]) => g);
757
+
758
+ const dates = tracks.map((t) => t.created_at).filter(Boolean) as string[];
759
+ const lastUpload = dates.length ? dates.sort().pop()! : null;
760
+
761
+ const followers = num(user.followers_count);
762
+
763
+ this.data.artists[username.toLowerCase()] = {
764
+ username: user.permalink ?? username,
765
+ display_name: user.full_name || user.username || username,
766
+ user_id: user.id,
767
+ permalink_url:
768
+ user.permalink_url ?? `https://soundcloud.com/${username}`,
769
+ followers,
770
+ track_count: num(user.track_count),
771
+ total_plays: totalPlays,
772
+ genres: topGenres,
773
+ last_upload: lastUpload,
774
+ known_track_ids: tracks.map((t) => t.id).slice(-MAX_KNOWN_TRACKS),
775
+ added_at: utcnow(),
776
+ last_updated: utcnow(),
777
+ };
778
+ this.save();
779
+
780
+ return `Added ${user.full_name || username} (${followers.toLocaleString()} followers, ${tracks.length} tracks)`;
781
+ }
782
+
783
+ /** Remove an artist from tracking. */
784
+ remove(username: string): string {
785
+ const key = username.toLowerCase();
786
+ for (const [k, artist] of Object.entries(this.data.artists)) {
787
+ if (k === key || (artist.username ?? "").toLowerCase() === key) {
788
+ const name = artist.display_name ?? k;
789
+ delete this.data.artists[k];
790
+ this.save();
791
+ return `Removed ${name}`;
792
+ }
793
+ }
794
+ return `Artist '${username}' not found`;
795
+ }
796
+
797
+ /** Print all tracked artists sorted by follower count. */
798
+ list(): void {
799
+ const artists = Object.values(this.data.artists).sort(
800
+ (a, b) => (b.followers ?? 0) - (a.followers ?? 0)
801
+ );
802
+ console.log(`\n=== Tracked Artists (${artists.length}) ===\n`);
803
+ for (const a of artists) {
804
+ const dormant = this.isDormant(a);
805
+ const status = dormant ? " [DORMANT]" : "";
806
+ console.log(`${a.display_name} (@${a.username})${status}`);
807
+ console.log(
808
+ ` ${(a.followers ?? 0).toLocaleString()} followers | ${a.track_count ?? 0} tracks`
809
+ );
810
+ if (a.last_upload) console.log(` Last upload: ${a.last_upload.slice(0, 10)}`);
811
+ console.log();
812
+ }
813
+ }
814
+ }
815
+
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();
870
+
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;
881
+ }
882
+
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;
889
+ }
890
+ }
891
+
892
+ const args = process.argv.slice(2);
893
+ const cmd = args[0];
894
+
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)}`
917
+ : "Token: None"
918
+ );
919
+ if (account.data.my_account) {
920
+ console.log(`Account: @${account.data.my_account.username}`);
921
+ console.log(
922
+ `Followers: ${account.data.follower_count || Object.keys(account.data.my_followers).length}`
923
+ );
924
+ }
925
+ const total = Object.keys(tracker.data.artists).length;
926
+ const dormant = Object.values(tracker.data.artists).filter(
927
+ (a) => (daysSince(a.last_upload ?? "") ?? 0) > DORMANT_DAYS
928
+ ).length;
929
+ console.log(
930
+ `Tracked artists: ${total} (${total - dormant} active, ${dormant} dormant)`
931
+ );
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`);
938
+
939
+ const [accountNotifs, releases] = await runFullCheck(api, config);
940
+
941
+ console.log("--- Account ---");
942
+ for (const n of accountNotifs) console.log(` ${n}`);
943
+ if (!accountNotifs.length) console.log(" No updates");
944
+
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");
948
+
949
+ console.log(`\nAPI calls: ${api.calls}`);
950
+ } else if (cmd === "cron") {
951
+ const [accountNotifs, releases] = await runFullCheck(api, config);
952
+
953
+ const lines: string[] = [];
954
+ if (accountNotifs.length) {
955
+ lines.push("**Account:**");
956
+ lines.push(...accountNotifs.map((n) => `- ${n}`));
957
+ lines.push("");
958
+ }
959
+ if (releases.length) {
960
+ lines.push("**New Releases:**");
961
+ for (const r of releases) {
962
+ lines.push(`- **${r.artist}** dropped: ${r.title}`);
963
+ lines.push(` ${r.url}`);
964
+ }
965
+ lines.push("");
966
+ }
967
+
968
+ 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
+ }
975
+ }
976
+
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.");
992
+ }
993
+ }
994
+
995
+ main().catch((e) => {
996
+ console.error(`Fatal error: ${e}`);
997
+ process.exit(1);
998
+ });