@rmdes/indiekit-endpoint-funkwhale 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/README.md +116 -0
- package/assets/styles.css +453 -0
- package/includes/@indiekit-endpoint-funkwhale-now-playing.njk +83 -0
- package/includes/@indiekit-endpoint-funkwhale-stats.njk +75 -0
- package/includes/@indiekit-endpoint-funkwhale-widget.njk +12 -0
- package/index.js +108 -0
- package/lib/controllers/dashboard.js +122 -0
- package/lib/controllers/favorites.js +110 -0
- package/lib/controllers/listenings.js +109 -0
- package/lib/controllers/now-playing.js +58 -0
- package/lib/controllers/stats.js +138 -0
- package/lib/funkwhale-client.js +187 -0
- package/lib/stats.js +228 -0
- package/lib/sync.js +160 -0
- package/lib/utils.js +242 -0
- package/locales/en.json +29 -0
- package/package.json +54 -0
- package/views/favorites.njk +60 -0
- package/views/funkwhale.njk +145 -0
- package/views/listenings.njk +65 -0
- package/views/partials/stats-summary.njk +18 -0
- package/views/partials/top-albums.njk +20 -0
- package/views/partials/top-artists.njk +13 -0
- package/views/stats.njk +130 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { IndiekitError } from "@indiekit/error";
|
|
2
|
+
|
|
3
|
+
export class FunkwhaleClient {
|
|
4
|
+
/**
|
|
5
|
+
* @param {object} options - Client options
|
|
6
|
+
* @param {string} options.instanceUrl - Funkwhale instance URL
|
|
7
|
+
* @param {string} options.token - API access token
|
|
8
|
+
* @param {string} options.username - Username to filter by
|
|
9
|
+
* @param {number} [options.cacheTtl] - Cache TTL in milliseconds
|
|
10
|
+
*/
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.instanceUrl = options.instanceUrl?.replace(/\/$/, ""); // Remove trailing slash
|
|
13
|
+
this.token = options.token;
|
|
14
|
+
this.username = options.username;
|
|
15
|
+
this.cacheTtl = options.cacheTtl || 900_000;
|
|
16
|
+
this.cache = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch from Funkwhale API with caching
|
|
21
|
+
* @param {string} endpoint - API endpoint path
|
|
22
|
+
* @param {object} [params] - Query parameters
|
|
23
|
+
* @returns {Promise<object>} - Response data
|
|
24
|
+
*/
|
|
25
|
+
async fetch(endpoint, params = {}) {
|
|
26
|
+
const url = new URL(endpoint, this.instanceUrl);
|
|
27
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
28
|
+
if (value !== undefined && value !== null) {
|
|
29
|
+
url.searchParams.set(key, String(value));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const cacheKey = url.toString();
|
|
34
|
+
|
|
35
|
+
// Check cache first
|
|
36
|
+
const cached = this.cache.get(cacheKey);
|
|
37
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
|
|
38
|
+
return cached.data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headers = {
|
|
42
|
+
Accept: "application/json",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (this.token) {
|
|
46
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const response = await fetch(url.toString(), { headers });
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw await IndiekitError.fromFetch(response);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
|
|
57
|
+
// Cache result
|
|
58
|
+
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
|
59
|
+
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get listening history
|
|
65
|
+
* @param {number} [page] - Page number
|
|
66
|
+
* @param {number} [pageSize] - Items per page
|
|
67
|
+
* @returns {Promise<object>} - Paginated listenings
|
|
68
|
+
*/
|
|
69
|
+
async getListenings(page = 1, pageSize = 50) {
|
|
70
|
+
return this.fetch("/api/v2/history/listenings", {
|
|
71
|
+
page,
|
|
72
|
+
page_size: pageSize,
|
|
73
|
+
scope: "all",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get all listenings by paginating through all pages
|
|
79
|
+
* @returns {Promise<Array>} - All listenings
|
|
80
|
+
*/
|
|
81
|
+
async getAllListenings() {
|
|
82
|
+
const allListenings = [];
|
|
83
|
+
let page = 1;
|
|
84
|
+
let hasMore = true;
|
|
85
|
+
|
|
86
|
+
while (hasMore) {
|
|
87
|
+
const response = await this.getListenings(page, 100);
|
|
88
|
+
allListenings.push(...response.results);
|
|
89
|
+
hasMore = response.next !== null;
|
|
90
|
+
page++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return allListenings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get new listenings since a given date
|
|
98
|
+
* Used for incremental sync
|
|
99
|
+
* @param {Date} since - Only fetch listenings after this date
|
|
100
|
+
* @returns {Promise<Array>} - New listenings
|
|
101
|
+
*/
|
|
102
|
+
async getNewListenings(since) {
|
|
103
|
+
const newListenings = [];
|
|
104
|
+
let page = 1;
|
|
105
|
+
let hasMore = true;
|
|
106
|
+
|
|
107
|
+
while (hasMore) {
|
|
108
|
+
const response = await this.getListenings(page, 100);
|
|
109
|
+
|
|
110
|
+
for (const listening of response.results) {
|
|
111
|
+
const listenedAt = new Date(listening.creation_date);
|
|
112
|
+
if (listenedAt > since) {
|
|
113
|
+
newListenings.push(listening);
|
|
114
|
+
} else {
|
|
115
|
+
// We've reached older listenings, stop
|
|
116
|
+
hasMore = false;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (hasMore && response.next !== null) {
|
|
122
|
+
page++;
|
|
123
|
+
} else {
|
|
124
|
+
hasMore = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return newListenings;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get favorite tracks
|
|
133
|
+
* @param {number} [page] - Page number
|
|
134
|
+
* @param {number} [pageSize] - Items per page
|
|
135
|
+
* @returns {Promise<object>} - Paginated favorites filtered to username
|
|
136
|
+
*/
|
|
137
|
+
async getFavorites(page = 1, pageSize = 50) {
|
|
138
|
+
const response = await this.fetch("/api/v2/favorites/tracks", {
|
|
139
|
+
page,
|
|
140
|
+
page_size: pageSize,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Filter to configured username only
|
|
144
|
+
if (this.username) {
|
|
145
|
+
response.results = response.results.filter(
|
|
146
|
+
(fav) => fav.actor?.preferred_username === this.username
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return response;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get all favorites for the configured user
|
|
155
|
+
* @returns {Promise<Array>} - All favorites
|
|
156
|
+
*/
|
|
157
|
+
async getAllFavorites() {
|
|
158
|
+
const allFavorites = [];
|
|
159
|
+
let page = 1;
|
|
160
|
+
let hasMore = true;
|
|
161
|
+
|
|
162
|
+
while (hasMore) {
|
|
163
|
+
const response = await this.getFavorites(page, 100);
|
|
164
|
+
allFavorites.push(...response.results);
|
|
165
|
+
hasMore = response.next !== null && response.results.length > 0;
|
|
166
|
+
page++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return allFavorites;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the most recent listening
|
|
174
|
+
* @returns {Promise<object|null>} - Most recent listening or null
|
|
175
|
+
*/
|
|
176
|
+
async getLatestListening() {
|
|
177
|
+
const response = await this.getListenings(1, 1);
|
|
178
|
+
return response.results?.[0] || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Clear the cache
|
|
183
|
+
*/
|
|
184
|
+
clearCache() {
|
|
185
|
+
this.cache.clear();
|
|
186
|
+
}
|
|
187
|
+
}
|
package/lib/stats.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get date match filter for a time period
|
|
3
|
+
* @param {string} period - 'all', 'week', or 'month'
|
|
4
|
+
* @returns {object} - MongoDB match filter
|
|
5
|
+
*/
|
|
6
|
+
function getDateMatch(period) {
|
|
7
|
+
const now = new Date();
|
|
8
|
+
switch (period) {
|
|
9
|
+
case "week":
|
|
10
|
+
return { listenedAt: { $gte: new Date(now - 7 * 24 * 60 * 60 * 1000) } };
|
|
11
|
+
case "month":
|
|
12
|
+
return { listenedAt: { $gte: new Date(now - 30 * 24 * 60 * 60 * 1000) } };
|
|
13
|
+
default:
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get top artists for a time period
|
|
20
|
+
* @param {object} db - MongoDB database
|
|
21
|
+
* @param {string} period - 'all', 'week', or 'month'
|
|
22
|
+
* @param {number} limit - Number of artists to return
|
|
23
|
+
* @returns {Promise<Array>} - Top artists
|
|
24
|
+
*/
|
|
25
|
+
export async function getTopArtists(db, period = "all", limit = 10) {
|
|
26
|
+
const match = getDateMatch(period);
|
|
27
|
+
const collection = db.collection("listenings");
|
|
28
|
+
|
|
29
|
+
return collection
|
|
30
|
+
.aggregate([
|
|
31
|
+
{ $match: match },
|
|
32
|
+
{
|
|
33
|
+
$group: {
|
|
34
|
+
_id: "$artistId",
|
|
35
|
+
name: { $first: "$artistName" },
|
|
36
|
+
playCount: { $sum: 1 },
|
|
37
|
+
totalDuration: { $sum: "$duration" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ $match: { _id: { $ne: null } } },
|
|
41
|
+
{ $sort: { playCount: -1 } },
|
|
42
|
+
{ $limit: limit },
|
|
43
|
+
])
|
|
44
|
+
.toArray();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get top albums for a time period
|
|
49
|
+
* @param {object} db - MongoDB database
|
|
50
|
+
* @param {string} period - 'all', 'week', or 'month'
|
|
51
|
+
* @param {number} limit - Number of albums to return
|
|
52
|
+
* @returns {Promise<Array>} - Top albums
|
|
53
|
+
*/
|
|
54
|
+
export async function getTopAlbums(db, period = "all", limit = 10) {
|
|
55
|
+
const match = getDateMatch(period);
|
|
56
|
+
const collection = db.collection("listenings");
|
|
57
|
+
|
|
58
|
+
return collection
|
|
59
|
+
.aggregate([
|
|
60
|
+
{ $match: { ...match, albumId: { $ne: null } } },
|
|
61
|
+
{
|
|
62
|
+
$group: {
|
|
63
|
+
_id: "$albumId",
|
|
64
|
+
title: { $first: "$albumTitle" },
|
|
65
|
+
artist: { $first: "$artistName" },
|
|
66
|
+
coverUrl: { $first: "$coverUrl" },
|
|
67
|
+
playCount: { $sum: 1 },
|
|
68
|
+
totalDuration: { $sum: "$duration" },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ $sort: { playCount: -1 } },
|
|
72
|
+
{ $limit: limit },
|
|
73
|
+
])
|
|
74
|
+
.toArray();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get listening trends (daily counts)
|
|
79
|
+
* @param {object} db - MongoDB database
|
|
80
|
+
* @param {number} days - Number of days to look back
|
|
81
|
+
* @returns {Promise<Array>} - Daily listening counts
|
|
82
|
+
*/
|
|
83
|
+
export async function getListeningTrends(db, days = 30) {
|
|
84
|
+
const startDate = new Date();
|
|
85
|
+
startDate.setDate(startDate.getDate() - days);
|
|
86
|
+
startDate.setHours(0, 0, 0, 0);
|
|
87
|
+
|
|
88
|
+
const collection = db.collection("listenings");
|
|
89
|
+
|
|
90
|
+
return collection
|
|
91
|
+
.aggregate([
|
|
92
|
+
{ $match: { listenedAt: { $gte: startDate } } },
|
|
93
|
+
{
|
|
94
|
+
$group: {
|
|
95
|
+
_id: {
|
|
96
|
+
$dateToString: { format: "%Y-%m-%d", date: "$listenedAt" },
|
|
97
|
+
},
|
|
98
|
+
count: { $sum: 1 },
|
|
99
|
+
duration: { $sum: "$duration" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ $sort: { _id: 1 } },
|
|
103
|
+
{
|
|
104
|
+
$project: {
|
|
105
|
+
_id: 0,
|
|
106
|
+
date: "$_id",
|
|
107
|
+
count: 1,
|
|
108
|
+
duration: 1,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
.toArray();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get summary statistics for a time period
|
|
117
|
+
* @param {object} db - MongoDB database
|
|
118
|
+
* @param {string} period - 'all', 'week', or 'month'
|
|
119
|
+
* @returns {Promise<object>} - Summary stats
|
|
120
|
+
*/
|
|
121
|
+
export async function getSummary(db, period = "all") {
|
|
122
|
+
const match = getDateMatch(period);
|
|
123
|
+
const collection = db.collection("listenings");
|
|
124
|
+
|
|
125
|
+
const result = await collection
|
|
126
|
+
.aggregate([
|
|
127
|
+
{ $match: match },
|
|
128
|
+
{
|
|
129
|
+
$group: {
|
|
130
|
+
_id: null,
|
|
131
|
+
totalPlays: { $sum: 1 },
|
|
132
|
+
totalDuration: { $sum: "$duration" },
|
|
133
|
+
uniqueTracks: { $addToSet: "$trackId" },
|
|
134
|
+
uniqueArtists: { $addToSet: "$artistId" },
|
|
135
|
+
uniqueAlbums: { $addToSet: "$albumId" },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
$project: {
|
|
140
|
+
_id: 0,
|
|
141
|
+
totalPlays: 1,
|
|
142
|
+
totalDuration: 1,
|
|
143
|
+
uniqueTracks: { $size: "$uniqueTracks" },
|
|
144
|
+
uniqueArtists: {
|
|
145
|
+
$size: {
|
|
146
|
+
$filter: {
|
|
147
|
+
input: "$uniqueArtists",
|
|
148
|
+
cond: { $ne: ["$$this", null] },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
uniqueAlbums: {
|
|
153
|
+
$size: {
|
|
154
|
+
$filter: {
|
|
155
|
+
input: "$uniqueAlbums",
|
|
156
|
+
cond: { $ne: ["$$this", null] },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
])
|
|
163
|
+
.toArray();
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
result[0] || {
|
|
167
|
+
totalPlays: 0,
|
|
168
|
+
totalDuration: 0,
|
|
169
|
+
uniqueTracks: 0,
|
|
170
|
+
uniqueArtists: 0,
|
|
171
|
+
uniqueAlbums: 0,
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get all stats for all time periods
|
|
178
|
+
* @param {object} db - MongoDB database
|
|
179
|
+
* @param {object} limits - Limits for top lists
|
|
180
|
+
* @returns {Promise<object>} - All stats
|
|
181
|
+
*/
|
|
182
|
+
export async function getAllStats(db, limits = {}) {
|
|
183
|
+
const topArtistsLimit = limits.topArtists || 10;
|
|
184
|
+
const topAlbumsLimit = limits.topAlbums || 10;
|
|
185
|
+
|
|
186
|
+
const [
|
|
187
|
+
summaryAll,
|
|
188
|
+
summaryMonth,
|
|
189
|
+
summaryWeek,
|
|
190
|
+
topArtistsAll,
|
|
191
|
+
topArtistsMonth,
|
|
192
|
+
topArtistsWeek,
|
|
193
|
+
topAlbumsAll,
|
|
194
|
+
topAlbumsMonth,
|
|
195
|
+
topAlbumsWeek,
|
|
196
|
+
trends,
|
|
197
|
+
] = await Promise.all([
|
|
198
|
+
getSummary(db, "all"),
|
|
199
|
+
getSummary(db, "month"),
|
|
200
|
+
getSummary(db, "week"),
|
|
201
|
+
getTopArtists(db, "all", topArtistsLimit),
|
|
202
|
+
getTopArtists(db, "month", topArtistsLimit),
|
|
203
|
+
getTopArtists(db, "week", topArtistsLimit),
|
|
204
|
+
getTopAlbums(db, "all", topAlbumsLimit),
|
|
205
|
+
getTopAlbums(db, "month", topAlbumsLimit),
|
|
206
|
+
getTopAlbums(db, "week", topAlbumsLimit),
|
|
207
|
+
getListeningTrends(db, 30),
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
summary: {
|
|
212
|
+
all: summaryAll,
|
|
213
|
+
month: summaryMonth,
|
|
214
|
+
week: summaryWeek,
|
|
215
|
+
},
|
|
216
|
+
topArtists: {
|
|
217
|
+
all: topArtistsAll,
|
|
218
|
+
month: topArtistsMonth,
|
|
219
|
+
week: topArtistsWeek,
|
|
220
|
+
},
|
|
221
|
+
topAlbums: {
|
|
222
|
+
all: topAlbumsAll,
|
|
223
|
+
month: topAlbumsMonth,
|
|
224
|
+
week: topAlbumsWeek,
|
|
225
|
+
},
|
|
226
|
+
trends,
|
|
227
|
+
};
|
|
228
|
+
}
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { FunkwhaleClient } from "./funkwhale-client.js";
|
|
2
|
+
import { getCoverUrl, getArtistName } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
let syncInterval = null;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Start background sync process
|
|
8
|
+
* @param {object} Indiekit - Indiekit instance
|
|
9
|
+
* @param {object} options - Plugin options
|
|
10
|
+
*/
|
|
11
|
+
export function startSync(Indiekit, options) {
|
|
12
|
+
const intervalMs = options.syncInterval || 300_000; // 5 minutes default
|
|
13
|
+
|
|
14
|
+
// Initial sync after a short delay
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
runSync(Indiekit, options).catch((err) => {
|
|
17
|
+
console.error("[Funkwhale] Initial sync error:", err.message);
|
|
18
|
+
});
|
|
19
|
+
}, 5000);
|
|
20
|
+
|
|
21
|
+
// Schedule recurring sync
|
|
22
|
+
syncInterval = setInterval(() => {
|
|
23
|
+
runSync(Indiekit, options).catch((err) => {
|
|
24
|
+
console.error("[Funkwhale] Sync error:", err.message);
|
|
25
|
+
});
|
|
26
|
+
}, intervalMs);
|
|
27
|
+
|
|
28
|
+
console.log(
|
|
29
|
+
`[Funkwhale] Background sync started (interval: ${intervalMs / 1000}s)`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Stop background sync
|
|
35
|
+
*/
|
|
36
|
+
export function stopSync() {
|
|
37
|
+
if (syncInterval) {
|
|
38
|
+
clearInterval(syncInterval);
|
|
39
|
+
syncInterval = null;
|
|
40
|
+
console.log("[Funkwhale] Background sync stopped");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run a single sync operation
|
|
46
|
+
* @param {object} Indiekit - Indiekit instance
|
|
47
|
+
* @param {object} options - Plugin options
|
|
48
|
+
* @returns {Promise<object>} - Sync result
|
|
49
|
+
*/
|
|
50
|
+
export async function runSync(Indiekit, options) {
|
|
51
|
+
const db = Indiekit.database;
|
|
52
|
+
if (!db) {
|
|
53
|
+
console.log("[Funkwhale] No database available, skipping sync");
|
|
54
|
+
return { synced: 0, error: "No database" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const client = new FunkwhaleClient({
|
|
58
|
+
instanceUrl: options.instanceUrl,
|
|
59
|
+
token: options.token,
|
|
60
|
+
username: options.username,
|
|
61
|
+
cacheTtl: 60_000, // Short cache for sync
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return syncListenings(db, client);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sync listenings to MongoDB
|
|
69
|
+
* @param {object} db - MongoDB database instance
|
|
70
|
+
* @param {FunkwhaleClient} client - Funkwhale API client
|
|
71
|
+
* @returns {Promise<object>} - Sync result
|
|
72
|
+
*/
|
|
73
|
+
export async function syncListenings(db, client) {
|
|
74
|
+
const collection = db.collection("listenings");
|
|
75
|
+
|
|
76
|
+
// Create index on funkwhaleId for upsert operations
|
|
77
|
+
await collection.createIndex({ funkwhaleId: 1 }, { unique: true });
|
|
78
|
+
// Create index on listenedAt for time-based queries
|
|
79
|
+
await collection.createIndex({ listenedAt: -1 });
|
|
80
|
+
// Create indexes for aggregation
|
|
81
|
+
await collection.createIndex({ artistId: 1 });
|
|
82
|
+
await collection.createIndex({ albumId: 1 });
|
|
83
|
+
|
|
84
|
+
// Get the latest synced listening
|
|
85
|
+
const latest = await collection.findOne({}, { sort: { listenedAt: -1 } });
|
|
86
|
+
const latestDate = latest?.listenedAt || new Date(0);
|
|
87
|
+
|
|
88
|
+
console.log(
|
|
89
|
+
`[Funkwhale] Syncing listenings since: ${latestDate.toISOString()}`
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Fetch new listenings
|
|
93
|
+
let newListenings;
|
|
94
|
+
if (latestDate.getTime() === 0) {
|
|
95
|
+
// First sync: get all
|
|
96
|
+
console.log("[Funkwhale] First sync, fetching all listenings...");
|
|
97
|
+
newListenings = await client.getAllListenings();
|
|
98
|
+
} else {
|
|
99
|
+
// Incremental sync
|
|
100
|
+
newListenings = await client.getNewListenings(latestDate);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (newListenings.length === 0) {
|
|
104
|
+
console.log("[Funkwhale] No new listenings to sync");
|
|
105
|
+
return { synced: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(`[Funkwhale] Found ${newListenings.length} new listenings`);
|
|
109
|
+
|
|
110
|
+
// Transform to our schema
|
|
111
|
+
const docs = newListenings.map((l) => transformListening(l));
|
|
112
|
+
|
|
113
|
+
// Upsert each document (in case of duplicates)
|
|
114
|
+
let synced = 0;
|
|
115
|
+
for (const doc of docs) {
|
|
116
|
+
try {
|
|
117
|
+
await collection.updateOne(
|
|
118
|
+
{ funkwhaleId: doc.funkwhaleId },
|
|
119
|
+
{ $set: doc },
|
|
120
|
+
{ upsert: true }
|
|
121
|
+
);
|
|
122
|
+
synced++;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// Ignore duplicate key errors
|
|
125
|
+
if (err.code !== 11000) {
|
|
126
|
+
console.error(`[Funkwhale] Error inserting listening:`, err.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`[Funkwhale] Synced ${synced} listenings`);
|
|
132
|
+
return { synced };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Transform Funkwhale listening to our schema
|
|
137
|
+
* @param {object} listening - Funkwhale listening object
|
|
138
|
+
* @returns {object} - Transformed document
|
|
139
|
+
*/
|
|
140
|
+
function transformListening(listening) {
|
|
141
|
+
const track = listening.track;
|
|
142
|
+
const artist = track.artist_credit?.[0]?.artist;
|
|
143
|
+
const album = track.album;
|
|
144
|
+
const upload = track.uploads?.[0];
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
funkwhaleId: listening.id,
|
|
148
|
+
trackId: track.id,
|
|
149
|
+
trackTitle: track.title,
|
|
150
|
+
trackFid: track.fid,
|
|
151
|
+
artistName: artist?.name || getArtistName(track),
|
|
152
|
+
artistId: artist?.id || null,
|
|
153
|
+
albumTitle: album?.title || null,
|
|
154
|
+
albumId: album?.id || null,
|
|
155
|
+
coverUrl: getCoverUrl(track),
|
|
156
|
+
duration: upload?.duration || 0,
|
|
157
|
+
listenedAt: new Date(listening.creation_date),
|
|
158
|
+
syncedAt: new Date(),
|
|
159
|
+
};
|
|
160
|
+
}
|