deaf-intelligence 1.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/dist/index.js ADDED
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DEAF Intelligence — MCP Server
4
+ *
5
+ * Spotify career intelligence. Track any artist, build history, get insights.
6
+ * Spotify deletes data after 2 years. We keep it forever.
7
+ *
8
+ * https://deaf.audio
9
+ */
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import * as z from "zod/v4";
13
+ import * as path from "path";
14
+ import * as fs from "fs";
15
+ import Database from "./db.js";
16
+ import { fullScrape } from "./scraper.js";
17
+ // Data directory — ~/.artist-os/ by default
18
+ const DATA_DIR = process.env.ARTIST_OS_DATA_DIR || path.join(process.env.HOME || "~", ".artist-os");
19
+ if (!fs.existsSync(DATA_DIR)) {
20
+ fs.mkdirSync(DATA_DIR, { recursive: true });
21
+ }
22
+ function getDbPath(artistId) {
23
+ return path.join(DATA_DIR, `${artistId}.db`);
24
+ }
25
+ function dbExists(artistId) {
26
+ return fs.existsSync(getDbPath(artistId));
27
+ }
28
+ function openDb(artistId) {
29
+ return new Database(getDbPath(artistId), { readonly: true });
30
+ }
31
+ function extractArtistId(input) {
32
+ const match = input.match(/artist[/:]([a-zA-Z0-9]+)/);
33
+ return match ? match[1] : input.trim();
34
+ }
35
+ // Create MCP server
36
+ const server = new McpServer({
37
+ name: "deaf-intelligence",
38
+ version: "1.0.1",
39
+ description: "Your personal Spotify archivist. Tracks everything, forgets nothing.",
40
+ });
41
+ // === TOOL: track_artist ===
42
+ server.registerTool("track_artist", {
43
+ description: "Start tracking a Spotify artist. Give me a Spotify URL or artist ID. I'll scrape their full profile — all tracks with play counts, cities, related artists — and store everything locally. From this moment, you're building a history that Spotify will delete but we won't.",
44
+ inputSchema: {
45
+ spotify_url_or_id: z.string().describe("Spotify artist URL (open.spotify.com/artist/...) or artist ID"),
46
+ },
47
+ }, async ({ spotify_url_or_id }) => {
48
+ const artistId = extractArtistId(spotify_url_or_id);
49
+ const dbPath = getDbPath(artistId);
50
+ try {
51
+ const result = await fullScrape(artistId, dbPath);
52
+ const o = result.overview;
53
+ const own = result.tracks.filter(t => !t.role.startsWith("FEAT on"));
54
+ const feats = result.tracks.filter(t => t.role.startsWith("FEAT on"));
55
+ let text = `# Now tracking: ${o.name}\n\n`;
56
+ text += `**${o.monthly_listeners.toLocaleString()}** monthly listeners · **${o.followers.toLocaleString()}** followers`;
57
+ if (o.verified)
58
+ text += ` · Verified`;
59
+ text += `\n\n`;
60
+ text += `**${result.tracks.length}** tracks · **${result.total_streams.toLocaleString()}** total streams\n`;
61
+ text += `${own.length} own songs · ${feats.length} features\n\n`;
62
+ if (o.top_cities.length) {
63
+ text += `## Top Cities\n`;
64
+ for (const c of o.top_cities) {
65
+ text += `- ${c.city}, ${c.country}: ${c.listeners.toLocaleString()}\n`;
66
+ }
67
+ text += `\n`;
68
+ }
69
+ text += `## Top Tracks\n`;
70
+ const sorted = [...result.tracks].sort((a, b) => b.playcount - a.playcount);
71
+ for (const t of sorted.slice(0, 10)) {
72
+ text += `- ${t.title}: ${t.playcount.toLocaleString()} [${t.role}]\n`;
73
+ }
74
+ if (sorted.length > 10)
75
+ text += `- ...and ${sorted.length - 10} more\n`;
76
+ text += `\n**${result.images.downloaded}** images downloaded`;
77
+ if (result.images.skipped)
78
+ text += ` (${result.images.skipped} already cached)`;
79
+ if (result.s4a)
80
+ text += `\n**S4A data included** — saves, demographics, segments, playlists`;
81
+ text += `\nData stored in \`${dbPath}\`. Run daily to build history.`;
82
+ return { content: [{ type: "text", text }] };
83
+ }
84
+ catch (e) {
85
+ return { content: [{ type: "text", text: `Error tracking artist: ${e.message}` }], isError: true };
86
+ }
87
+ });
88
+ // === TOOL: show_stats ===
89
+ server.registerTool("show_stats", {
90
+ description: "Show current stats and history for a tracked artist.",
91
+ inputSchema: { artist_id: z.string().describe("Spotify artist ID") },
92
+ }, async ({ artist_id }) => {
93
+ const aid = extractArtistId(artist_id);
94
+ if (!dbExists(aid)) {
95
+ return { content: [{ type: "text", text: `Not tracked yet. Use track_artist first.` }], isError: true };
96
+ }
97
+ const db = openDb(aid);
98
+ const artist = db.prepare("SELECT name, verified, bio, brand_color FROM artist WHERE spotify_id = ?").get(aid);
99
+ const metrics = db.prepare("SELECT metric_name, metric_value, delta_value, delta_pct, scraped_at FROM daily_stats WHERE artist_id = ? ORDER BY scraped_at DESC").all(aid);
100
+ const totalStreams = db.prepare("SELECT SUM(play_count) as total FROM song_snapshots WHERE artist_id = ? AND scraped_at = (SELECT MAX(scraped_at) FROM song_snapshots WHERE artist_id = ?)").get(aid, aid);
101
+ const trackCount = db.prepare("SELECT COUNT(*) as c FROM tracks WHERE artist_id = ?").get(aid);
102
+ const days = db.prepare("SELECT COUNT(DISTINCT date(scraped_at, 'unixepoch')) as d FROM daily_stats WHERE artist_id = ?").get(aid);
103
+ const cities = db.prepare("SELECT location_name, listener_count FROM locations WHERE artist_id = ? ORDER BY scraped_at DESC, listener_count DESC LIMIT 5").all(aid);
104
+ const playlists = db.prepare("SELECT DISTINCT playlist_name, playlist_owner FROM discovered_on WHERE artist_id = ? ORDER BY scraped_at DESC LIMIT 10").all(aid);
105
+ const links = db.prepare("SELECT link_name, link_url FROM external_links WHERE artist_id = ? ORDER BY link_name").all(aid);
106
+ const latest = {};
107
+ const history = {};
108
+ for (const row of metrics) {
109
+ if (!latest[row.metric_name])
110
+ latest[row.metric_name] = row;
111
+ if (!history[row.metric_name])
112
+ history[row.metric_name] = [];
113
+ history[row.metric_name].push(row);
114
+ }
115
+ let text = `# ${artist?.name || aid}`;
116
+ if (artist?.verified)
117
+ text += ` · Verified`;
118
+ text += `\n\n`;
119
+ if (artist?.bio)
120
+ text += `> ${artist.bio.slice(0, 200)}\n\n`;
121
+ text += `Tracking for **${days?.d || 1}** day(s)`;
122
+ if (artist?.brand_color)
123
+ text += ` · Brand color: ${artist.brand_color}`;
124
+ text += `\n\n`;
125
+ text += `| Metric | Value | Change |\n|--------|-------|--------|\n`;
126
+ const ml = latest.monthly_listeners;
127
+ if (ml) {
128
+ const delta = ml.delta_value ? ` ${ml.delta_value > 0 ? "+" : ""}${Math.round(ml.delta_value).toLocaleString()} (${ml.delta_pct?.toFixed(1)}%)` : "—";
129
+ text += `| Monthly Listeners | ${Math.round(ml.metric_value).toLocaleString()} | ${delta} |\n`;
130
+ }
131
+ const fol = latest.followers;
132
+ if (fol)
133
+ text += `| Followers | ${Math.round(fol.metric_value).toLocaleString()} | — |\n`;
134
+ const wr = latest.world_rank;
135
+ if (wr && wr.metric_value > 0)
136
+ text += `| World Rank | #${Math.round(wr.metric_value).toLocaleString()} | — |\n`;
137
+ text += `| Total Streams | ${totalStreams?.total?.toLocaleString() || "?"} | — |\n`;
138
+ text += `| Tracks | ${trackCount?.c || "?"} | — |\n`;
139
+ if (cities.length) {
140
+ text += `\n## Top Cities\n`;
141
+ for (const c of cities)
142
+ text += `- ${c.location_name}: ${c.listener_count?.toLocaleString()}\n`;
143
+ }
144
+ if (playlists.length) {
145
+ text += `\n## Discovered On\n`;
146
+ for (const p of playlists)
147
+ text += `- ${p.playlist_name}${p.playlist_owner ? ` (by ${p.playlist_owner})` : ""}\n`;
148
+ }
149
+ if (links.length) {
150
+ text += `\n## Links\n`;
151
+ for (const l of links)
152
+ text += `- ${l.link_name}: ${l.link_url}\n`;
153
+ }
154
+ if ((days?.d || 0) > 1) {
155
+ text += `\n## Trend (${days.d} days)\n`;
156
+ for (const metric of ["monthly_listeners", "followers"]) {
157
+ const h = history[metric];
158
+ if (h?.length > 1) {
159
+ const oldest = h[h.length - 1].metric_value;
160
+ const newest = h[0].metric_value;
161
+ const change = newest - oldest;
162
+ const pct = oldest > 0 ? ((change / oldest) * 100).toFixed(1) : "?";
163
+ text += `- ${metric}: ${Math.round(oldest).toLocaleString()} → ${Math.round(newest).toLocaleString()} (${change > 0 ? "+" : ""}${Math.round(change).toLocaleString()}, ${pct}%)\n`;
164
+ }
165
+ }
166
+ }
167
+ db.close();
168
+ return { content: [{ type: "text", text }] };
169
+ });
170
+ // === TOOL: show_tracks ===
171
+ server.registerTool("show_tracks", {
172
+ description: "Show all tracks with play counts, velocity, and artist roles.",
173
+ inputSchema: {
174
+ artist_id: z.string().describe("Spotify artist ID"),
175
+ sort_by: z.enum(["playcount", "velocity", "title", "date"]).default("playcount").describe("Sort order"),
176
+ },
177
+ }, async ({ artist_id, sort_by }) => {
178
+ const aid = extractArtistId(artist_id);
179
+ if (!dbExists(aid)) {
180
+ return { content: [{ type: "text", text: `Not tracked yet.` }], isError: true };
181
+ }
182
+ const db = openDb(aid);
183
+ const artist = db.prepare("SELECT name FROM artist WHERE spotify_id = ?").get(aid);
184
+ const orderClause = sort_by === "velocity" ? "COALESCE(s.delta_value, 0) DESC"
185
+ : sort_by === "title" ? "s.track_title ASC"
186
+ : sort_by === "date" ? "t.release_year DESC, t.release_date DESC"
187
+ : "s.play_count DESC";
188
+ const tracks = db.prepare(`
189
+ SELECT s.track_title, s.play_count, s.delta_value, s.delta_pct, t.release_date, t.release_year, t.release_type, t.role
190
+ FROM song_snapshots s
191
+ LEFT JOIN tracks t ON s.track_id = t.spotify_id AND s.artist_id = t.artist_id
192
+ WHERE s.artist_id = ?
193
+ AND s.scraped_at = (SELECT MAX(scraped_at) FROM song_snapshots WHERE artist_id = ?)
194
+ ORDER BY ${orderClause}
195
+ `).all(aid, aid);
196
+ let total = 0;
197
+ let text = `# ${artist?.name || aid} — ${tracks.length} tracks\n\n`;
198
+ text += `| # | Track | Streams | Δ/day | Role | Type | Released |\n`;
199
+ text += `|---|-------|---------|-------|------|------|----------|\n`;
200
+ tracks.forEach((t, i) => {
201
+ total += t.play_count || 0;
202
+ const delta = t.delta_value != null ? `${t.delta_value > 0 ? "+" : ""}${t.delta_value.toLocaleString()}` : "—";
203
+ const released = t.release_date ? t.release_date.slice(0, 10) : (t.release_year || "?");
204
+ text += `| ${i + 1} | ${t.track_title} | ${t.play_count?.toLocaleString()} | ${delta} | ${t.role || "?"} | ${t.release_type || "?"} | ${released} |\n`;
205
+ });
206
+ text += `\n**Total: ${total.toLocaleString()} streams**\n`;
207
+ db.close();
208
+ return { content: [{ type: "text", text }] };
209
+ });
210
+ // === TOOL: show_cities ===
211
+ server.registerTool("show_cities", {
212
+ description: "Show top cities where the artist's listeners are.",
213
+ inputSchema: { artist_id: z.string().describe("Spotify artist ID") },
214
+ }, async ({ artist_id }) => {
215
+ const aid = extractArtistId(artist_id);
216
+ if (!dbExists(aid))
217
+ return { content: [{ type: "text", text: "Not tracked yet." }], isError: true };
218
+ const db = openDb(aid);
219
+ const cities = db.prepare("SELECT location_name, listener_count FROM locations WHERE artist_id = ? ORDER BY scraped_at DESC, listener_count DESC LIMIT 10").all(aid);
220
+ db.close();
221
+ let text = "## Top Cities\n\n";
222
+ for (const c of cities)
223
+ text += `- ${c.location_name}: ${c.listener_count?.toLocaleString()} listeners\n`;
224
+ return { content: [{ type: "text", text }] };
225
+ });
226
+ // === TOOL: show_related ===
227
+ server.registerTool("show_related", {
228
+ description: "Show related artists.",
229
+ inputSchema: { artist_id: z.string().describe("Spotify artist ID") },
230
+ }, async ({ artist_id }) => {
231
+ const aid = extractArtistId(artist_id);
232
+ if (!dbExists(aid))
233
+ return { content: [{ type: "text", text: "Not tracked yet." }], isError: true };
234
+ const db = openDb(aid);
235
+ const related = db.prepare("SELECT DISTINCT related_name FROM related_artists WHERE artist_id = ? ORDER BY scraped_at DESC LIMIT 20").all(aid);
236
+ db.close();
237
+ let text = "## Related Artists\n\n";
238
+ for (const r of related)
239
+ text += `- ${r.related_name}\n`;
240
+ return { content: [{ type: "text", text }] };
241
+ });
242
+ // === TOOL: list_artists ===
243
+ server.registerTool("list_artists", {
244
+ description: "List all artists currently being tracked.",
245
+ inputSchema: {},
246
+ }, async () => {
247
+ const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith(".db"));
248
+ if (!files.length)
249
+ return { content: [{ type: "text", text: "No artists tracked yet. Use track_artist to start." }] };
250
+ let text = "## Tracked Artists\n\n";
251
+ for (const file of files) {
252
+ const aid = file.replace(".db", "");
253
+ try {
254
+ const db = new Database(path.join(DATA_DIR, file), { readonly: true });
255
+ const artist = db.prepare("SELECT name FROM artist LIMIT 1").get();
256
+ const days = db.prepare("SELECT COUNT(DISTINCT date(scraped_at, 'unixepoch')) as d FROM daily_stats").get();
257
+ const tracks = db.prepare("SELECT COUNT(*) as c FROM tracks").get();
258
+ const total = db.prepare("SELECT SUM(play_count) as t FROM song_snapshots WHERE scraped_at = (SELECT MAX(scraped_at) FROM song_snapshots)").get();
259
+ text += `- **${artist?.name || aid}** — ${tracks?.c || 0} tracks, ${total?.t?.toLocaleString() || "?"} streams, ${days?.d || 0} days tracked\n`;
260
+ db.close();
261
+ }
262
+ catch {
263
+ text += `- ${aid} (error reading)\n`;
264
+ }
265
+ }
266
+ return { content: [{ type: "text", text }] };
267
+ });
268
+ // === TOOL: run_scrape ===
269
+ server.registerTool("run_scrape", {
270
+ description: "Manually trigger a fresh scrape for a tracked artist. Adds new daily snapshot to the database.",
271
+ inputSchema: { artist_id: z.string().describe("Spotify artist ID") },
272
+ }, async ({ artist_id }) => {
273
+ const aid = extractArtistId(artist_id);
274
+ const dbPath = getDbPath(aid);
275
+ try {
276
+ const result = await fullScrape(aid, dbPath);
277
+ const text = `Scrape complete for ${result.overview.name}.\n` +
278
+ `${result.tracks.length} tracks, ${result.total_streams.toLocaleString()} total streams.\n` +
279
+ `ML: ${result.overview.monthly_listeners.toLocaleString()} · Followers: ${result.overview.followers.toLocaleString()}`;
280
+ return { content: [{ type: "text", text }] };
281
+ }
282
+ catch (e) {
283
+ return { content: [{ type: "text", text: `Scrape error: ${e.message}` }], isError: true };
284
+ }
285
+ });
286
+ // === TOOL: show_insights ===
287
+ server.registerTool("show_insights", {
288
+ description: "Pre-computed intelligence for a tracked artist. Lifetime velocity per track, release cadence, catalog health, solo vs feature performance, geographic concentration, projected milestones, and actionable recommendations. Works from the very first scrape — no history needed.",
289
+ inputSchema: { artist_id: z.string().describe("Spotify artist ID") },
290
+ }, async ({ artist_id }) => {
291
+ const aid = extractArtistId(artist_id);
292
+ if (!dbExists(aid))
293
+ return { content: [{ type: "text", text: "Not tracked yet." }], isError: true };
294
+ const db = openDb(aid);
295
+ const artist = db.prepare("SELECT name FROM artist WHERE spotify_id = ?").get(aid);
296
+ const now = Math.floor(Date.now() / 1000);
297
+ // All tracks with release dates and latest play counts
298
+ const tracks = db.prepare(`
299
+ SELECT t.title, t.release_date, t.role, t.release_type,
300
+ s.play_count, s.track_id
301
+ FROM tracks t
302
+ JOIN song_snapshots s ON s.track_id = t.spotify_id AND s.artist_id = t.artist_id
303
+ WHERE t.artist_id = ?
304
+ AND s.scraped_at = (SELECT MAX(scraped_at) FROM song_snapshots WHERE artist_id = ?)
305
+ ORDER BY s.play_count DESC
306
+ `).all(aid, aid);
307
+ const cities = db.prepare("SELECT location_name, listener_count FROM locations WHERE artist_id = ? ORDER BY scraped_at DESC, listener_count DESC LIMIT 10").all(aid);
308
+ const totalStreams = tracks.reduce((s, t) => s + (t.play_count || 0), 0);
309
+ const insights = [];
310
+ for (const t of tracks) {
311
+ if (!t.release_date || !t.play_count)
312
+ continue;
313
+ const releaseTs = new Date(t.release_date).getTime() / 1000;
314
+ const days = Math.max(1, Math.floor((now - releaseTs) / 86400));
315
+ insights.push({
316
+ title: t.title, streams: t.play_count, days,
317
+ velocity: t.play_count / days,
318
+ share: totalStreams > 0 ? (t.play_count / totalStreams) * 100 : 0,
319
+ role: t.role || "?", release_date: t.release_date.slice(0, 10),
320
+ });
321
+ }
322
+ // Sort by velocity for age-normalized ranking
323
+ const byVelocity = [...insights].sort((a, b) => b.velocity - a.velocity);
324
+ let text = `# ${artist?.name || aid} — Intelligence Report\n\n`;
325
+ // --- Velocity ranking (age-normalized) ---
326
+ text += `## Track Velocity (streams/day since release)\n\n`;
327
+ text += `| # | Track | Streams/day | Total | Age (days) | Share | Role |\n`;
328
+ text += `|---|-------|-------------|-------|------------|-------|------|\n`;
329
+ for (let i = 0; i < byVelocity.length; i++) {
330
+ const t = byVelocity[i];
331
+ text += `| ${i + 1} | ${t.title} | **${t.velocity.toFixed(1)}** | ${t.streams.toLocaleString()} | ${t.days} | ${t.share.toFixed(1)}% | ${t.role} |\n`;
332
+ }
333
+ // --- Catalog health ---
334
+ text += `\n## Catalog Health\n\n`;
335
+ if (byVelocity.length > 0) {
336
+ const top1Share = byVelocity.length > 0 ? insights.sort((a, b) => b.streams - a.streams)[0]?.share : 0;
337
+ const top3Share = insights.sort((a, b) => b.streams - a.streams).slice(0, 3).reduce((s, t) => s + t.share, 0);
338
+ text += `- **Top track concentration:** ${top1Share.toFixed(1)}% of all streams`;
339
+ if (top1Share > 30)
340
+ text += ` ⚠️ High dependency on single track`;
341
+ text += `\n`;
342
+ text += `- **Top 3 concentration:** ${top3Share.toFixed(1)}% of all streams`;
343
+ if (top3Share > 70)
344
+ text += ` ⚠️ Catalog too top-heavy`;
345
+ text += `\n`;
346
+ // Old vs new split (6 months = 180 days)
347
+ const fresh = insights.filter(t => t.days <= 180);
348
+ const catalog = insights.filter(t => t.days > 180);
349
+ const freshStreams = fresh.reduce((s, t) => s + t.streams, 0);
350
+ const catalogStreams = catalog.reduce((s, t) => s + t.streams, 0);
351
+ const freshPct = totalStreams > 0 ? (freshStreams / totalStreams) * 100 : 0;
352
+ text += `- **Fresh (<6mo):** ${fresh.length} tracks, ${freshPct.toFixed(1)}% of streams\n`;
353
+ text += `- **Catalog (>6mo):** ${catalog.length} tracks, ${(100 - freshPct).toFixed(1)}% of streams\n`;
354
+ }
355
+ // --- Solo vs Feature ---
356
+ text += `\n## Solo vs Feature Performance\n\n`;
357
+ const solo = insights.filter(t => t.role === "SOLO");
358
+ const feat = insights.filter(t => t.role.startsWith("ft."));
359
+ const featOn = insights.filter(t => t.role.startsWith("FEAT on"));
360
+ const avg = (arr) => arr.length ? arr.reduce((s, t) => s + t.velocity, 0) / arr.length : 0;
361
+ text += `| Type | Tracks | Avg streams/day | Total streams |\n`;
362
+ text += `|------|--------|-----------------|---------------|\n`;
363
+ if (solo.length)
364
+ text += `| Solo | ${solo.length} | ${avg(solo).toFixed(1)} | ${solo.reduce((s, t) => s + t.streams, 0).toLocaleString()} |\n`;
365
+ if (feat.length)
366
+ text += `| ft. (own + guest) | ${feat.length} | ${avg(feat).toFixed(1)} | ${feat.reduce((s, t) => s + t.streams, 0).toLocaleString()} |\n`;
367
+ if (featOn.length)
368
+ text += `| Featured on others | ${featOn.length} | ${avg(featOn).toFixed(1)} | ${featOn.reduce((s, t) => s + t.streams, 0).toLocaleString()} |\n`;
369
+ // --- Release cadence ---
370
+ text += `\n## Release Cadence\n\n`;
371
+ const dates = insights.map(t => t.release_date).sort();
372
+ if (dates.length >= 2) {
373
+ const gaps = [];
374
+ for (let i = 1; i < dates.length; i++) {
375
+ const d1 = new Date(dates[i - 1]).getTime();
376
+ const d2 = new Date(dates[i]).getTime();
377
+ gaps.push(Math.round((d2 - d1) / 86400000));
378
+ }
379
+ const avgGap = gaps.reduce((s, g) => s + g, 0) / gaps.length;
380
+ const lastRelease = dates[dates.length - 1];
381
+ const daysSinceLast = Math.floor((now - new Date(lastRelease).getTime() / 1000) / 86400);
382
+ text += `- **Average gap:** ${Math.round(avgGap)} days between releases\n`;
383
+ text += `- **Last release:** ${lastRelease} (${daysSinceLast} days ago)`;
384
+ if (daysSinceLast > avgGap * 1.5)
385
+ text += ` ⚠️ Overdue (${Math.round(avgGap * 1.5 - daysSinceLast)} days past average)`;
386
+ text += `\n`;
387
+ text += `- **Shortest gap:** ${Math.min(...gaps)} days · **Longest:** ${Math.max(...gaps)} days\n`;
388
+ }
389
+ else {
390
+ text += `- Not enough releases to calculate cadence\n`;
391
+ }
392
+ // --- Geographic concentration ---
393
+ text += `\n## Geographic Concentration\n\n`;
394
+ if (cities.length >= 2) {
395
+ const totalCity = cities.reduce((s, c) => s + (c.listener_count || 0), 0);
396
+ const topCity = cities[0];
397
+ const topPct = totalCity > 0 ? ((topCity.listener_count || 0) / totalCity) * 100 : 0;
398
+ text += `- **Top city:** ${topCity.location_name} — ${topPct.toFixed(0)}% of top-city listeners`;
399
+ if (topPct > 50)
400
+ text += ` (heavily concentrated)`;
401
+ text += `\n`;
402
+ for (const c of cities) {
403
+ const pct = totalCity > 0 ? ((c.listener_count || 0) / totalCity) * 100 : 0;
404
+ text += `- ${c.location_name}: ${c.listener_count?.toLocaleString()} (${pct.toFixed(1)}%)\n`;
405
+ }
406
+ }
407
+ // --- Projected milestones ---
408
+ text += `\n## Projected Milestones\n\n`;
409
+ const milestones = [10000, 50000, 100000, 500000, 1000000];
410
+ const projections = [];
411
+ for (const t of byVelocity.slice(0, 5)) {
412
+ for (const m of milestones) {
413
+ if (t.streams < m && t.velocity > 0) {
414
+ const daysToGo = Math.ceil((m - t.streams) / t.velocity);
415
+ const targetDate = new Date(Date.now() + daysToGo * 86400000).toISOString().slice(0, 10);
416
+ projections.push(`- **${t.title}** → ${(m / 1000).toFixed(0)}k streams by ~${targetDate} (${daysToGo} days at ${t.velocity.toFixed(1)}/day)`);
417
+ break; // Only show next milestone per track
418
+ }
419
+ }
420
+ }
421
+ if (projections.length) {
422
+ text += projections.join("\n") + "\n";
423
+ }
424
+ else {
425
+ text += `- All top tracks already past major milestones\n`;
426
+ }
427
+ // --- Best performer recommendation ---
428
+ text += `\n## Recommendations\n\n`;
429
+ if (byVelocity.length > 0) {
430
+ text += `- **Push:** ${byVelocity[0].title} (${byVelocity[0].velocity.toFixed(1)} streams/day — highest velocity)\n`;
431
+ if (byVelocity.length > 1) {
432
+ const newest = [...insights].sort((a, b) => a.days - b.days)[0];
433
+ if (newest.title !== byVelocity[0].title) {
434
+ text += `- **Watch:** ${newest.title} (newest release, ${newest.days} days old, ${newest.velocity.toFixed(1)}/day)\n`;
435
+ }
436
+ }
437
+ const topHeavy = insights.sort((a, b) => b.streams - a.streams)[0];
438
+ if (topHeavy.share > 30) {
439
+ text += `- **Risk:** ${topHeavy.title} holds ${topHeavy.share.toFixed(0)}% of all streams — diversify catalog\n`;
440
+ }
441
+ }
442
+ db.close();
443
+ return { content: [{ type: "text", text }] };
444
+ });
445
+ // Start
446
+ async function main() {
447
+ const transport = new StdioServerTransport();
448
+ await server.connect(transport);
449
+ }
450
+ main().catch(console.error);
@@ -0,0 +1,111 @@
1
+ export declare function getAnonymousToken(artistId: string): Promise<string>;
2
+ export interface ArtistOverview {
3
+ artist_id: string;
4
+ name: string;
5
+ verified: boolean;
6
+ bio: string | null;
7
+ profile_image: string | null;
8
+ header_image: string | null;
9
+ gallery_images: string[];
10
+ brand_color: string | null;
11
+ monthly_listeners: number;
12
+ followers: number;
13
+ world_rank: number;
14
+ top_cities: {
15
+ city: string;
16
+ country: string;
17
+ listeners: number;
18
+ }[];
19
+ top_tracks: {
20
+ track_id: string;
21
+ title: string;
22
+ playcount: number;
23
+ artists: string[];
24
+ }[];
25
+ related_artists: {
26
+ name: string;
27
+ id: string;
28
+ followers: number;
29
+ }[];
30
+ discovered_on: {
31
+ name: string;
32
+ owner: string;
33
+ }[];
34
+ external_links: {
35
+ name: string;
36
+ url: string;
37
+ }[];
38
+ latest_release: {
39
+ name: string;
40
+ type: string;
41
+ label: string;
42
+ date: string;
43
+ } | null;
44
+ singles_count: number;
45
+ albums_count: number;
46
+ }
47
+ export declare function getArtistOverview(token: string, artistId: string): Promise<ArtistOverview>;
48
+ export interface TrackData {
49
+ track_id: string;
50
+ title: string;
51
+ playcount: number;
52
+ popularity: number;
53
+ artists: string[];
54
+ artist_ids: string[];
55
+ role: string;
56
+ album_name: string;
57
+ album_id: string;
58
+ release_date: string;
59
+ release_type: string;
60
+ cover_art: string | null;
61
+ }
62
+ export declare function getAllTracks(token: string, artistId: string): Promise<TrackData[]>;
63
+ export declare function storeData(dbPath: string, overview: ArtistOverview, tracks: TrackData[]): void;
64
+ export declare function downloadImages(artistId: string, overview: ArtistOverview, tracks: TrackData[], dataDir: string): Promise<{
65
+ downloaded: number;
66
+ skipped: number;
67
+ }>;
68
+ export declare function generateWorkbook(dbPath: string, dataDir: string, s4a?: S4AData | null): Promise<void>;
69
+ export declare function connectS4A(artistId: string): Promise<{
70
+ success: boolean;
71
+ name?: string;
72
+ error?: string;
73
+ }>;
74
+ export declare function hasS4ASession(): boolean;
75
+ export interface S4AData {
76
+ gender?: any;
77
+ genderByAge?: any;
78
+ topCities?: any;
79
+ locations?: any;
80
+ segments?: any;
81
+ audienceStats?: any;
82
+ catalog?: any;
83
+ playlists?: {
84
+ curated?: any;
85
+ listener?: any;
86
+ personalized?: any;
87
+ };
88
+ upcoming?: any;
89
+ videos?: any;
90
+ accountOwner?: any;
91
+ permissions?: any;
92
+ canvasPermissions?: any;
93
+ campaignEligibility?: any;
94
+ teamMembership?: any;
95
+ perSong?: Record<string, any>;
96
+ }
97
+ export declare function scrapeS4ADirect(artistId: string): Promise<S4AData | null>;
98
+ export declare function scrapeS4A(artistId: string, outputDir: string): Promise<{
99
+ files: number;
100
+ }>;
101
+ export interface ScrapeResult {
102
+ overview: ArtistOverview;
103
+ tracks: TrackData[];
104
+ total_streams: number;
105
+ images: {
106
+ downloaded: number;
107
+ skipped: number;
108
+ };
109
+ s4a: S4AData | null;
110
+ }
111
+ export declare function fullScrape(artistId: string, dbPath?: string): Promise<ScrapeResult>;