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.
- package/.env.local +2 -0
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/LICENSE +22 -0
- package/README.md +554 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +172 -0
- package/dist/cli.js.map +1 -0
- package/dist/generator/embedGenerator.d.ts +38 -0
- package/dist/generator/embedGenerator.d.ts.map +1 -0
- package/dist/generator/embedGenerator.js +92 -0
- package/dist/generator/embedGenerator.js.map +1 -0
- package/dist/generator/feedGenerator.d.ts +50 -0
- package/dist/generator/feedGenerator.d.ts.map +1 -0
- package/dist/generator/feedGenerator.js +167 -0
- package/dist/generator/feedGenerator.js.map +1 -0
- package/dist/generator/podcastFeedGenerator.d.ts +54 -0
- package/dist/generator/podcastFeedGenerator.d.ts.map +1 -0
- package/dist/generator/podcastFeedGenerator.js +173 -0
- package/dist/generator/podcastFeedGenerator.js.map +1 -0
- package/dist/generator/proceduralCoverGenerator.d.ts +51 -0
- package/dist/generator/proceduralCoverGenerator.d.ts.map +1 -0
- package/dist/generator/proceduralCoverGenerator.js +228 -0
- package/dist/generator/proceduralCoverGenerator.js.map +1 -0
- package/dist/generator/siteGenerator.d.ts +55 -0
- package/dist/generator/siteGenerator.d.ts.map +1 -0
- package/dist/generator/siteGenerator.js +539 -0
- package/dist/generator/siteGenerator.js.map +1 -0
- package/dist/generator/templateEngine.d.ts +13 -0
- package/dist/generator/templateEngine.d.ts.map +1 -0
- package/dist/generator/templateEngine.js +146 -0
- package/dist/generator/templateEngine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/catalogParser.d.ts +13 -0
- package/dist/parser/catalogParser.d.ts.map +1 -0
- package/dist/parser/catalogParser.js +120 -0
- package/dist/parser/catalogParser.js.map +1 -0
- package/dist/tools/generate-codes.d.ts +14 -0
- package/dist/tools/generate-codes.d.ts.map +1 -0
- package/dist/tools/generate-codes.js +274 -0
- package/dist/tools/generate-codes.js.map +1 -0
- package/dist/tools/generate-sea-pair.d.ts +14 -0
- package/dist/tools/generate-sea-pair.d.ts.map +1 -0
- package/dist/tools/generate-sea-pair.js +111 -0
- package/dist/tools/generate-sea-pair.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/audioUtils.d.ts +9 -0
- package/dist/utils/audioUtils.d.ts.map +1 -0
- package/dist/utils/audioUtils.js +67 -0
- package/dist/utils/audioUtils.js.map +1 -0
- package/dist/utils/configUtils.d.ts +11 -0
- package/dist/utils/configUtils.d.ts.map +1 -0
- package/dist/utils/configUtils.js +50 -0
- package/dist/utils/configUtils.js.map +1 -0
- package/dist/utils/fileUtils.d.ts +14 -0
- package/dist/utils/fileUtils.d.ts.map +1 -0
- package/dist/utils/fileUtils.js +73 -0
- package/dist/utils/fileUtils.js.map +1 -0
- package/examples/artist-free/README.md +36 -0
- package/examples/artist-paycurtain/README.md +49 -0
- package/examples/label/README.md +33 -0
- package/gundb-keypair.json +8 -0
- package/logo.svg +30 -0
- package/package-lock.json +1176 -0
- package/package.json +42 -0
- package/public/assets/community-registry.js +291 -0
- package/public/assets/download-stats.js +263 -0
- package/public/assets/player.js +219 -0
- package/public/assets/style.css +1170 -0
- package/public/assets/theme-widget.js +353 -0
- package/public/assets/unlock-codes.js +225 -0
- package/public/atom.xml +22 -0
- package/public/catalog.m3u +3 -0
- package/public/feed.xml +22 -0
- package/public/image.png +0 -0
- package/public/index.html +249 -0
- package/public/logo.svg +30 -0
- package/public/releases/chirichetto/Homologo - Chirichetto.wav +0 -0
- package/public/releases/chirichetto/cover.png +0 -0
- package/public/releases/chirichetto/embed-code.txt +16 -0
- package/public/releases/chirichetto/embed-compact.txt +8 -0
- package/public/releases/chirichetto/embed.html +39 -0
- package/public/releases/chirichetto/index.html +389 -0
- package/public/releases/chirichetto/playlist.m3u +3 -0
- package/templates/dark/assets/community-registry.js +291 -0
- package/templates/dark/assets/download-stats.js +263 -0
- package/templates/dark/assets/player.js +219 -0
- package/templates/dark/assets/style.css +740 -0
- package/templates/dark/index.hbs +73 -0
- package/templates/dark/layout.hbs +84 -0
- package/templates/dark/release.hbs +212 -0
- package/templates/default/assets/community-registry.js +291 -0
- package/templates/default/assets/download-stats.js +263 -0
- package/templates/default/assets/player.js +219 -0
- package/templates/default/assets/style.css +1170 -0
- package/templates/default/assets/theme-widget.js +353 -0
- package/templates/default/assets/unlock-codes.js +225 -0
- package/templates/default/index.hbs +188 -0
- package/templates/default/layout.hbs +117 -0
- package/templates/default/release.hbs +553 -0
- package/templates/minimal/assets/community-registry.js +291 -0
- package/templates/minimal/assets/download-stats.js +263 -0
- package/templates/minimal/assets/player.js +219 -0
- package/templates/minimal/assets/style.css +796 -0
- package/templates/minimal/index.hbs +73 -0
- package/templates/minimal/layout.hbs +84 -0
- package/templates/minimal/release.hbs +212 -0
- package/templates/retro/assets/community-registry.js +291 -0
- package/templates/retro/assets/download-stats.js +263 -0
- package/templates/retro/assets/player.js +219 -0
- package/templates/retro/assets/style.css +872 -0
- package/templates/retro/index.hbs +73 -0
- package/templates/retro/layout.hbs +84 -0
- package/templates/retro/release.hbs +212 -0
- package/templates/translucent/assets/community-registry.js +291 -0
- package/templates/translucent/assets/download-stats.js +263 -0
- package/templates/translucent/assets/player.js +219 -0
- package/templates/translucent/assets/style.css +1352 -0
- package/templates/translucent/index.hbs +73 -0
- package/templates/translucent/layout.hbs +84 -0
- package/templates/translucent/release.hbs +212 -0
- package/website/community.html +492 -0
- package/website/index.html +195 -0
- package/website/styles.css +396 -0
- package/website/tunecamp.svg +30 -0
|
@@ -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
|
+
})();
|