tunecamp 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.
Files changed (132) hide show
  1. package/.env.local +2 -0
  2. package/.vercel/README.txt +11 -0
  3. package/.vercel/project.json +1 -0
  4. package/LICENSE +22 -0
  5. package/README.md +554 -0
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +172 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/generator/embedGenerator.d.ts +38 -0
  11. package/dist/generator/embedGenerator.d.ts.map +1 -0
  12. package/dist/generator/embedGenerator.js +92 -0
  13. package/dist/generator/embedGenerator.js.map +1 -0
  14. package/dist/generator/feedGenerator.d.ts +50 -0
  15. package/dist/generator/feedGenerator.d.ts.map +1 -0
  16. package/dist/generator/feedGenerator.js +167 -0
  17. package/dist/generator/feedGenerator.js.map +1 -0
  18. package/dist/generator/podcastFeedGenerator.d.ts +54 -0
  19. package/dist/generator/podcastFeedGenerator.d.ts.map +1 -0
  20. package/dist/generator/podcastFeedGenerator.js +173 -0
  21. package/dist/generator/podcastFeedGenerator.js.map +1 -0
  22. package/dist/generator/proceduralCoverGenerator.d.ts +51 -0
  23. package/dist/generator/proceduralCoverGenerator.d.ts.map +1 -0
  24. package/dist/generator/proceduralCoverGenerator.js +228 -0
  25. package/dist/generator/proceduralCoverGenerator.js.map +1 -0
  26. package/dist/generator/siteGenerator.d.ts +55 -0
  27. package/dist/generator/siteGenerator.d.ts.map +1 -0
  28. package/dist/generator/siteGenerator.js +539 -0
  29. package/dist/generator/siteGenerator.js.map +1 -0
  30. package/dist/generator/templateEngine.d.ts +13 -0
  31. package/dist/generator/templateEngine.d.ts.map +1 -0
  32. package/dist/generator/templateEngine.js +146 -0
  33. package/dist/generator/templateEngine.js.map +1 -0
  34. package/dist/index.d.ts +12 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +32 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/parser/catalogParser.d.ts +13 -0
  39. package/dist/parser/catalogParser.d.ts.map +1 -0
  40. package/dist/parser/catalogParser.js +120 -0
  41. package/dist/parser/catalogParser.js.map +1 -0
  42. package/dist/tools/generate-codes.d.ts +14 -0
  43. package/dist/tools/generate-codes.d.ts.map +1 -0
  44. package/dist/tools/generate-codes.js +274 -0
  45. package/dist/tools/generate-codes.js.map +1 -0
  46. package/dist/tools/generate-sea-pair.d.ts +14 -0
  47. package/dist/tools/generate-sea-pair.d.ts.map +1 -0
  48. package/dist/tools/generate-sea-pair.js +111 -0
  49. package/dist/tools/generate-sea-pair.js.map +1 -0
  50. package/dist/types/index.d.ts +117 -0
  51. package/dist/types/index.d.ts.map +1 -0
  52. package/dist/types/index.js +5 -0
  53. package/dist/types/index.js.map +1 -0
  54. package/dist/utils/audioUtils.d.ts +9 -0
  55. package/dist/utils/audioUtils.d.ts.map +1 -0
  56. package/dist/utils/audioUtils.js +67 -0
  57. package/dist/utils/audioUtils.js.map +1 -0
  58. package/dist/utils/configUtils.d.ts +11 -0
  59. package/dist/utils/configUtils.d.ts.map +1 -0
  60. package/dist/utils/configUtils.js +50 -0
  61. package/dist/utils/configUtils.js.map +1 -0
  62. package/dist/utils/fileUtils.d.ts +14 -0
  63. package/dist/utils/fileUtils.d.ts.map +1 -0
  64. package/dist/utils/fileUtils.js +73 -0
  65. package/dist/utils/fileUtils.js.map +1 -0
  66. package/examples/artist-free/README.md +36 -0
  67. package/examples/artist-paycurtain/README.md +49 -0
  68. package/examples/label/README.md +33 -0
  69. package/gundb-keypair.json +8 -0
  70. package/logo.svg +30 -0
  71. package/package-lock.json +1176 -0
  72. package/package.json +42 -0
  73. package/public/assets/community-registry.js +291 -0
  74. package/public/assets/download-stats.js +263 -0
  75. package/public/assets/player.js +219 -0
  76. package/public/assets/style.css +1170 -0
  77. package/public/assets/theme-widget.js +353 -0
  78. package/public/assets/unlock-codes.js +225 -0
  79. package/public/atom.xml +22 -0
  80. package/public/catalog.m3u +3 -0
  81. package/public/feed.xml +22 -0
  82. package/public/image.png +0 -0
  83. package/public/index.html +249 -0
  84. package/public/logo.svg +30 -0
  85. package/public/releases/chirichetto/Homologo - Chirichetto.wav +0 -0
  86. package/public/releases/chirichetto/cover.png +0 -0
  87. package/public/releases/chirichetto/embed-code.txt +16 -0
  88. package/public/releases/chirichetto/embed-compact.txt +8 -0
  89. package/public/releases/chirichetto/embed.html +39 -0
  90. package/public/releases/chirichetto/index.html +389 -0
  91. package/public/releases/chirichetto/playlist.m3u +3 -0
  92. package/templates/dark/assets/community-registry.js +291 -0
  93. package/templates/dark/assets/download-stats.js +263 -0
  94. package/templates/dark/assets/player.js +219 -0
  95. package/templates/dark/assets/style.css +740 -0
  96. package/templates/dark/index.hbs +73 -0
  97. package/templates/dark/layout.hbs +84 -0
  98. package/templates/dark/release.hbs +212 -0
  99. package/templates/default/assets/community-registry.js +291 -0
  100. package/templates/default/assets/download-stats.js +263 -0
  101. package/templates/default/assets/player.js +219 -0
  102. package/templates/default/assets/style.css +1170 -0
  103. package/templates/default/assets/theme-widget.js +353 -0
  104. package/templates/default/assets/unlock-codes.js +225 -0
  105. package/templates/default/index.hbs +188 -0
  106. package/templates/default/layout.hbs +117 -0
  107. package/templates/default/release.hbs +553 -0
  108. package/templates/minimal/assets/community-registry.js +291 -0
  109. package/templates/minimal/assets/download-stats.js +263 -0
  110. package/templates/minimal/assets/player.js +219 -0
  111. package/templates/minimal/assets/style.css +796 -0
  112. package/templates/minimal/index.hbs +73 -0
  113. package/templates/minimal/layout.hbs +84 -0
  114. package/templates/minimal/release.hbs +212 -0
  115. package/templates/retro/assets/community-registry.js +291 -0
  116. package/templates/retro/assets/download-stats.js +263 -0
  117. package/templates/retro/assets/player.js +219 -0
  118. package/templates/retro/assets/style.css +872 -0
  119. package/templates/retro/index.hbs +73 -0
  120. package/templates/retro/layout.hbs +84 -0
  121. package/templates/retro/release.hbs +212 -0
  122. package/templates/translucent/assets/community-registry.js +291 -0
  123. package/templates/translucent/assets/download-stats.js +263 -0
  124. package/templates/translucent/assets/player.js +219 -0
  125. package/templates/translucent/assets/style.css +1352 -0
  126. package/templates/translucent/index.hbs +73 -0
  127. package/templates/translucent/layout.hbs +84 -0
  128. package/templates/translucent/release.hbs +212 -0
  129. package/website/community.html +492 -0
  130. package/website/index.html +195 -0
  131. package/website/styles.css +396 -0
  132. package/website/tunecamp.svg +30 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "tunecamp",
3
+ "version": "1.0.0",
4
+ "description": "A modern static site generator for musicians and music labels, inspired by Faircamp",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "tunecamp": "./dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch",
13
+ "start": "node dist/cli.js",
14
+ "test": "echo \"Error: no test specified\" && exit 1"
15
+ },
16
+ "keywords": [
17
+ "music",
18
+ "static-site-generator",
19
+ "bandcamp",
20
+ "artist",
21
+ "album",
22
+ "audio"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "chalk": "^5.3.0",
28
+ "commander": "^11.1.0",
29
+ "fs-extra": "^11.2.0",
30
+ "glob": "^10.3.10",
31
+ "gun": "^0.2020.1241",
32
+ "handlebars": "^4.7.8",
33
+ "music-metadata": "^8.1.4",
34
+ "vercel": "^50.3.2",
35
+ "yaml": "^2.3.4"
36
+ },
37
+ "devDependencies": {
38
+ "@types/fs-extra": "^11.0.4",
39
+ "@types/node": "^20.10.5",
40
+ "typescript": "^5.3.3"
41
+ }
42
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tunecamp Community Registry
3
+ * Auto-registers Tunecamp sites to a global public GunDB registry
4
+ *
5
+ * When a visitor loads any Tunecamp site, it automatically registers
6
+ * the site in a decentralized community directory.
7
+ */
8
+
9
+ (function() {
10
+ 'use strict';
11
+
12
+ // Public GunDB peers for the community registry
13
+ const REGISTRY_PEERS = [
14
+ 'https://gun.defucc.me/gun',
15
+ 'https://gun.o8.is/gun',
16
+ 'https://shogun-relay.scobrudot.dev/gun',
17
+ 'https://relay.peer.ooo/gun',
18
+ ];
19
+
20
+ const REGISTRY_ROOT = 'shogun';
21
+ const REGISTRY_NAMESPACE = 'tunecamp-community';
22
+ const REGISTRY_VERSION = '1.0';
23
+
24
+ /**
25
+ * TunecampCommunityRegistry
26
+ * Handles auto-registration and discovery of Tunecamp sites
27
+ */
28
+ class TunecampCommunityRegistry {
29
+ constructor() {
30
+ this.gun = null;
31
+ this.initialized = false;
32
+ this.siteData = null;
33
+ }
34
+
35
+ /**
36
+ * Initialize GunDB connection
37
+ */
38
+ async init() {
39
+ if (typeof Gun === 'undefined') {
40
+ console.warn('GunDB not loaded. Community registry disabled.');
41
+ return false;
42
+ }
43
+
44
+ this.gun = Gun({
45
+ peers: REGISTRY_PEERS,
46
+ localStorage: true,
47
+ });
48
+
49
+ this.initialized = true;
50
+ console.log('🌐 Tunecamp Community Registry initialized');
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Generate a unique site ID from title + artist (content-based, not URL-based)
56
+ * This prevents duplicates when the same site is deployed to multiple URLs (e.g., Vercel previews)
57
+ */
58
+ generateSiteId(siteInfo) {
59
+ // Use title + artist as the unique identifier
60
+ // This way the same site deployed to different URLs won't create duplicates
61
+ const identifier = `${(siteInfo.title || 'untitled').toLowerCase().trim()}::${(siteInfo.artistName || 'unknown').toLowerCase().trim()}`;
62
+
63
+ // Create a simple hash
64
+ let hash = 0;
65
+ for (let i = 0; i < identifier.length; i++) {
66
+ const char = identifier.charCodeAt(i);
67
+ hash = ((hash << 5) - hash) + char;
68
+ hash = hash & hash;
69
+ }
70
+ return Math.abs(hash).toString(36);
71
+ }
72
+
73
+ /**
74
+ * Register current site in the community registry
75
+ * @param {Object} siteInfo - Site information
76
+ * @param {string} siteInfo.url - Site URL
77
+ * @param {string} siteInfo.title - Catalog/Artist title
78
+ * @param {string} siteInfo.description - Site description
79
+ * @param {string} siteInfo.artistName - Artist name (optional)
80
+ * @param {string} siteInfo.coverImage - Cover image URL (optional)
81
+ */
82
+ async registerSite(siteInfo) {
83
+ if (!this.initialized || !this.gun) {
84
+ console.warn('Registry not initialized');
85
+ return false;
86
+ }
87
+
88
+ const siteId = this.generateSiteId(siteInfo);
89
+ const now = Date.now();
90
+
91
+ // Check if already registered recently (within 24h)
92
+ const lastRegistration = localStorage.getItem('tunecamp_registered');
93
+ if (lastRegistration) {
94
+ const lastTime = parseInt(lastRegistration, 10);
95
+ if (now - lastTime < 24 * 60 * 60 * 1000) {
96
+ // Already registered recently, just update lastSeen
97
+ this.gun
98
+ .get(REGISTRY_ROOT)
99
+ .get(REGISTRY_NAMESPACE)
100
+ .get('sites')
101
+ .get(siteId)
102
+ .get('lastSeen')
103
+ .put(now);
104
+ return true;
105
+ }
106
+ }
107
+
108
+ const siteRecord = {
109
+ id: siteId,
110
+ url: siteInfo.url,
111
+ title: siteInfo.title || 'Untitled',
112
+ description: siteInfo.description || '',
113
+ artistName: siteInfo.artistName || '',
114
+ coverImage: siteInfo.coverImage || '',
115
+ registeredAt: now,
116
+ lastSeen: now,
117
+ version: REGISTRY_VERSION,
118
+ };
119
+
120
+ return new Promise((resolve) => {
121
+ this.gun
122
+ .get(REGISTRY_ROOT)
123
+ .get(REGISTRY_NAMESPACE)
124
+ .get('sites')
125
+ .get(siteId)
126
+ .put(siteRecord, (ack) => {
127
+ if (ack.err) {
128
+ console.warn('Failed to register site:', ack.err);
129
+ resolve(false);
130
+ } else {
131
+ localStorage.setItem('tunecamp_registered', now.toString());
132
+ console.log('✅ Site registered in Tunecamp Community');
133
+ resolve(true);
134
+ }
135
+ });
136
+
137
+ // Timeout fallback
138
+ setTimeout(() => resolve(true), 3000);
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Get all registered sites
144
+ * @param {function} callback - Called with array of sites
145
+ */
146
+ async getAllSites(callback) {
147
+ if (!this.initialized || !this.gun) {
148
+ callback([]);
149
+ return;
150
+ }
151
+
152
+ const sites = [];
153
+ const seenIds = new Set();
154
+
155
+ this.gun
156
+ .get(REGISTRY_ROOT)
157
+ .get(REGISTRY_NAMESPACE)
158
+ .get('sites')
159
+ .map()
160
+ .once((data, key) => {
161
+ if (data && data.url && !seenIds.has(key)) {
162
+ seenIds.add(key);
163
+ sites.push({
164
+ id: key,
165
+ url: data.url,
166
+ title: data.title || 'Untitled',
167
+ description: data.description || '',
168
+ artistName: data.artistName || '',
169
+ coverImage: data.coverImage || '',
170
+ registeredAt: data.registeredAt,
171
+ lastSeen: data.lastSeen,
172
+ });
173
+ }
174
+ });
175
+
176
+ // Give time to collect all sites
177
+ setTimeout(() => {
178
+ // Sort by lastSeen (most recent first)
179
+ sites.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
180
+ callback(sites);
181
+ }, 2000);
182
+ }
183
+
184
+ /**
185
+ * Subscribe to new sites (real-time)
186
+ * @param {function} callback - Called when a new site is added
187
+ */
188
+ subscribeToSites(callback) {
189
+ if (!this.initialized || !this.gun) {
190
+ return () => {};
191
+ }
192
+
193
+ const ref = this.gun
194
+ .get(REGISTRY_ROOT)
195
+ .get(REGISTRY_NAMESPACE)
196
+ .get('sites')
197
+ .map()
198
+ .on((data, key) => {
199
+ if (data && data.url) {
200
+ callback({
201
+ id: key,
202
+ url: data.url,
203
+ title: data.title || 'Untitled',
204
+ description: data.description || '',
205
+ artistName: data.artistName || '',
206
+ coverImage: data.coverImage || '',
207
+ registeredAt: data.registeredAt,
208
+ lastSeen: data.lastSeen,
209
+ });
210
+ }
211
+ });
212
+
213
+ return () => ref.off();
214
+ }
215
+
216
+ /**
217
+ * Format timestamp for display
218
+ */
219
+ formatDate(timestamp) {
220
+ if (!timestamp) return 'Unknown';
221
+ const date = new Date(timestamp);
222
+ return date.toLocaleDateString('en-US', {
223
+ year: 'numeric',
224
+ month: 'short',
225
+ day: 'numeric',
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Get site count
231
+ */
232
+ async getSiteCount() {
233
+ return new Promise((resolve) => {
234
+ let count = 0;
235
+
236
+ this.gun
237
+ .get(REGISTRY_ROOT)
238
+ .get(REGISTRY_NAMESPACE)
239
+ .get('sites')
240
+ .map()
241
+ .once((data) => {
242
+ if (data && data.url) count++;
243
+ });
244
+
245
+ setTimeout(() => resolve(count), 2000);
246
+ });
247
+ }
248
+ }
249
+
250
+ // Expose globally
251
+ window.TunecampCommunityRegistry = TunecampCommunityRegistry;
252
+
253
+ // Auto-register on page load if site data is available
254
+ document.addEventListener('DOMContentLoaded', async function() {
255
+ // Check if this is a Tunecamp site (has site metadata)
256
+ const siteTitle = document.querySelector('meta[name="tunecamp-title"]')?.content ||
257
+ document.querySelector('.site-title a')?.textContent ||
258
+ document.title;
259
+
260
+ const siteDescription = document.querySelector('meta[name="description"]')?.content || '';
261
+ const artistName = document.querySelector('meta[name="tunecamp-artist"]')?.content ||
262
+ document.querySelector('.release-artist')?.textContent?.replace('by ', '') || '';
263
+
264
+ // Get cover image if available
265
+ const coverImage = document.querySelector('meta[property="og:image"]')?.content ||
266
+ document.querySelector('.release-cover-large img')?.src ||
267
+ document.querySelector('.header-image')?.src || '';
268
+
269
+ // Only register if we have a valid URL and it looks like a Tunecamp site
270
+ const isTunecampSite = document.querySelector('.site-footer a[href*="tunecamp"]') ||
271
+ document.querySelector('meta[name="generator"][content*="Tunecamp"]');
272
+
273
+ if (isTunecampSite || window.TUNECAMP_SITE) {
274
+ const registry = new TunecampCommunityRegistry();
275
+ const initialized = await registry.init();
276
+
277
+ if (initialized) {
278
+ await registry.registerSite({
279
+ url: window.location.origin + window.location.pathname.replace(/\/[^\/]*$/, '/'),
280
+ title: siteTitle,
281
+ description: siteDescription,
282
+ artistName: artistName,
283
+ coverImage: coverImage,
284
+ });
285
+ }
286
+
287
+ // Make registry available globally
288
+ window.tunecampRegistry = registry;
289
+ }
290
+ });
291
+ })();
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Tunecamp Download Stats - GunDB Client
3
+ * Decentralized download counter using GunDB public peers
4
+ *
5
+ * Uses a public GunDB space to track and display download counts.
6
+ * No authentication required - anyone can read/increment counts.
7
+ */
8
+
9
+ (function() {
10
+ 'use strict';
11
+
12
+ // Default public GunDB peers
13
+ const DEFAULT_PEERS = [
14
+ 'https://gun.defucc.me/gun',
15
+ 'https://gun.o8.is/gun',
16
+ 'https://shogun-relay.scobrudot.dev/gun',
17
+ 'https://relay.peer.ooo/gun',
18
+ ];
19
+
20
+ /**
21
+ * TunecampDownloadStats class
22
+ * Tracks download counts via GunDB
23
+ */
24
+ class TunecampDownloadStats {
25
+ /**
26
+ * Initialize the download stats system
27
+ * @param {Object} options - Configuration options
28
+ * @param {Array} options.peers - GunDB peer URLs (default: public peers)
29
+ * @param {string} options.namespace - GunDB namespace (default: 'tunecamp-stats')
30
+ */
31
+ constructor(options = {}) {
32
+ this.peers = options.peers || DEFAULT_PEERS;
33
+ this.root = options.root || 'shogun';
34
+ this.namespace = options.namespace || 'tunecamp-stats';
35
+ this.gun = null;
36
+ this.initialized = false;
37
+
38
+ // Initialize GunDB when script loads
39
+ this.init();
40
+ }
41
+
42
+ /**
43
+ * Initialize GunDB connection
44
+ */
45
+ async init() {
46
+ // Check if Gun is available
47
+ if (typeof Gun === 'undefined') {
48
+ console.warn('GunDB not loaded. Download stats disabled.');
49
+ return;
50
+ }
51
+
52
+ // Initialize Gun with peers
53
+ this.gun = Gun({
54
+ peers: this.peers,
55
+ localStorage: true,
56
+ });
57
+
58
+ this.initialized = true;
59
+ console.log('📊 Tunecamp Download Stats initialized');
60
+ }
61
+
62
+ /**
63
+ * Wait for Gun to be ready
64
+ */
65
+ async waitForInit() {
66
+ if (this.initialized) return;
67
+
68
+ return new Promise((resolve, reject) => {
69
+ let attempts = 0;
70
+ const maxAttempts = 50; // 5 seconds max
71
+
72
+ const check = () => {
73
+ if (this.initialized) {
74
+ resolve();
75
+ } else if (attempts >= maxAttempts) {
76
+ reject(new Error('GunDB initialization timeout'));
77
+ } else {
78
+ attempts++;
79
+ setTimeout(check, 100);
80
+ }
81
+ };
82
+ check();
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Get download count for a release
88
+ * @param {string} releaseSlug - The release identifier
89
+ * @returns {Promise<number>} Download count
90
+ */
91
+ async getDownloadCount(releaseSlug) {
92
+ try {
93
+ await this.waitForInit();
94
+ } catch (e) {
95
+ return 0;
96
+ }
97
+
98
+ if (!this.gun) return 0;
99
+
100
+ return new Promise((resolve) => {
101
+ this.gun
102
+ .get(this.root).get(this.namespace)
103
+ .get('releases')
104
+ .get(releaseSlug)
105
+ .get('downloads')
106
+ .once((data) => {
107
+ resolve(data ? parseInt(data, 10) || 0 : 0);
108
+ });
109
+
110
+ // Timeout fallback
111
+ setTimeout(() => resolve(0), 3000);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Get download count for a specific track
117
+ * @param {string} releaseSlug - The release identifier
118
+ * @param {string} trackId - The track identifier (filename or index)
119
+ * @returns {Promise<number>} Download count
120
+ */
121
+ async getTrackDownloadCount(releaseSlug, trackId) {
122
+ try {
123
+ await this.waitForInit();
124
+ } catch (e) {
125
+ return 0;
126
+ }
127
+
128
+ if (!this.gun) return 0;
129
+
130
+ return new Promise((resolve) => {
131
+ this.gun
132
+ .get(this.root).get(this.namespace)
133
+ .get('releases')
134
+ .get(releaseSlug)
135
+ .get('tracks')
136
+ .get(trackId)
137
+ .get('downloads')
138
+ .once((data) => {
139
+ resolve(data ? parseInt(data, 10) || 0 : 0);
140
+ });
141
+
142
+ // Timeout fallback
143
+ setTimeout(() => resolve(0), 3000);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Increment download count for a release
149
+ * @param {string} releaseSlug - The release identifier
150
+ * @returns {Promise<number>} New download count
151
+ */
152
+ async incrementDownloadCount(releaseSlug) {
153
+ try {
154
+ await this.waitForInit();
155
+ } catch (e) {
156
+ return 0;
157
+ }
158
+
159
+ if (!this.gun) return 0;
160
+
161
+ const currentCount = await this.getDownloadCount(releaseSlug);
162
+ const newCount = currentCount + 1;
163
+
164
+ return new Promise((resolve) => {
165
+ this.gun
166
+ .get(this.root).get(this.namespace)
167
+ .get('releases')
168
+ .get(releaseSlug)
169
+ .get('downloads')
170
+ .put(newCount, (ack) => {
171
+ if (ack.err) {
172
+ console.error('Error incrementing download count:', ack.err);
173
+ resolve(currentCount);
174
+ } else {
175
+ resolve(newCount);
176
+ }
177
+ });
178
+
179
+ // Timeout fallback
180
+ setTimeout(() => resolve(newCount), 2000);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Increment download count for a specific track
186
+ * @param {string} releaseSlug - The release identifier
187
+ * @param {string} trackId - The track identifier
188
+ * @returns {Promise<number>} New download count
189
+ */
190
+ async incrementTrackDownloadCount(releaseSlug, trackId) {
191
+ try {
192
+ await this.waitForInit();
193
+ } catch (e) {
194
+ return 0;
195
+ }
196
+
197
+ if (!this.gun) return 0;
198
+
199
+ const currentCount = await this.getTrackDownloadCount(releaseSlug, trackId);
200
+ const newCount = currentCount + 1;
201
+
202
+ return new Promise((resolve) => {
203
+ this.gun
204
+ .get(this.root).get(this.namespace)
205
+ .get('releases')
206
+ .get(releaseSlug)
207
+ .get('tracks')
208
+ .get(trackId)
209
+ .get('downloads')
210
+ .put(newCount, (ack) => {
211
+ if (ack.err) {
212
+ console.error('Error incrementing track download count:', ack.err);
213
+ resolve(currentCount);
214
+ } else {
215
+ resolve(newCount);
216
+ }
217
+ });
218
+
219
+ // Timeout fallback
220
+ setTimeout(() => resolve(newCount), 2000);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Subscribe to download count changes (real-time updates)
226
+ * @param {string} releaseSlug - The release identifier
227
+ * @param {function} callback - Called with new count on each update
228
+ */
229
+ subscribeToDownloadCount(releaseSlug, callback) {
230
+ if (!this.gun) {
231
+ callback(0);
232
+ return () => {};
233
+ }
234
+
235
+ const ref = this.gun
236
+ .get(this.root).get(this.namespace)
237
+ .get('releases')
238
+ .get(releaseSlug)
239
+ .get('downloads');
240
+
241
+ ref.on((data) => {
242
+ callback(data ? parseInt(data, 10) || 0 : 0);
243
+ });
244
+
245
+ // Return unsubscribe function
246
+ return () => ref.off();
247
+ }
248
+
249
+ /**
250
+ * Format download count for display
251
+ * @param {number} count - Download count
252
+ * @returns {string} Formatted string (e.g., "1.2K", "3.5M")
253
+ */
254
+ formatCount(count) {
255
+ if (count < 1000) return count.toString();
256
+ if (count < 1000000) return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
257
+ return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
258
+ }
259
+ }
260
+
261
+ // Expose globally
262
+ window.TunecampDownloadStats = TunecampDownloadStats;
263
+ })();