@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 +118 -194
- package/openclaw.plugin.json +1 -47
- package/package.json +1 -4
- package/soundcloud_watcher.ts +179 -273
package/index.ts
CHANGED
|
@@ -1,259 +1,183 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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;
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
logger.info('Running SoundCloud check...');
|
|
71
|
-
const result = await runWatcherScript(['cron']);
|
|
71
|
+
Already configured!
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
logger.info('SoundCloud updates found:', result.stdout);
|
|
77
|
+
To update credentials, edit:
|
|
78
|
+
\`~/.openclaw/secrets/soundcloud.env\`
|
|
81
79
|
|
|
82
|
-
|
|
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
|
-
|
|
87
|
+
## Steps:
|
|
110
88
|
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
141
|
-
handler: async (
|
|
142
|
-
|
|
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 (
|
|
189
|
-
const
|
|
190
|
-
return { text:
|
|
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 (
|
|
198
|
-
const
|
|
199
|
-
return { text:
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
return { text:
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
return { text:
|
|
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 (
|
|
231
|
-
const
|
|
232
|
-
return { text:
|
|
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
|
-
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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
|
},
|
package/soundcloud_watcher.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
1
|
/**
|
|
3
|
-
* SoundCloud Watcher:
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
85
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
this.log("Token refresh rate limited (429)");
|
|
310
269
|
return false;
|
|
311
270
|
}
|
|
312
271
|
if (!resp.ok) {
|
|
313
|
-
|
|
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
|
-
|
|
278
|
+
this.log("Token refreshed");
|
|
320
279
|
return true;
|
|
321
280
|
} catch (e) {
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
512
|
-
return notifications;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
this.log(`Follower count unchanged (${currentCount}), skipping pagination`);
|
|
559
504
|
}
|
|
560
505
|
|
|
561
|
-
|
|
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
|
-
|
|
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(
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
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)
|
|
811
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
console.log
|
|
880
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
893
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
921
|
-
|
|
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) >
|
|
802
|
+
(a) => (daysSince(a.last_upload ?? "") ?? 0) > this.dormantDays
|
|
928
803
|
).length;
|
|
929
|
-
|
|
804
|
+
lines.push(
|
|
930
805
|
`Tracked artists: ${total} (${total - dormant} active, ${dormant} dormant)`
|
|
931
806
|
);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
if (
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|