strapi-plugin-radiocult-uploader 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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/admin/App-Cd-TZrqC.mjs +1179 -0
- package/dist/admin/App-DTOuR7aY.js +1199 -0
- package/dist/admin/Settings-B3cZR7VF.mjs +516 -0
- package/dist/admin/Settings-DHv9SmRU.js +534 -0
- package/dist/admin/en-CTCtUg23.mjs +8 -0
- package/dist/admin/en-D_61ojL7.js +8 -0
- package/dist/admin/index-BLAtUo_j.js +334 -0
- package/dist/admin/index-Dj3e3A79.mjs +318 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/api.d.ts +107 -0
- package/dist/admin/src/components/Initializer.d.ts +5 -0
- package/dist/admin/src/components/Panel.d.ts +18 -0
- package/dist/admin/src/components/PluginIcon.d.ts +2 -0
- package/dist/admin/src/index.d.ts +3 -0
- package/dist/admin/src/pages/App.d.ts +2 -0
- package/dist/admin/src/pages/HomePage.d.ts +6 -0
- package/dist/admin/src/pages/Settings.d.ts +8 -0
- package/dist/admin/src/pages/Uploader.d.ts +10 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/theme/tokens.d.ts +96 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/admin/tokens-ACCekCH6.mjs +161 -0
- package/dist/admin/tokens-CDeNcvZq.js +178 -0
- package/dist/server/index.js +1193 -0
- package/dist/server/index.mjs +1165 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +17 -0
- package/dist/server/src/content-types/index.d.ts +51 -0
- package/dist/server/src/content-types/track-record/schema.json.d.ts +29 -0
- package/dist/server/src/controllers/index.d.ts +21 -0
- package/dist/server/src/controllers/settings.d.ts +16 -0
- package/dist/server/src/controllers/uploader.d.ts +10 -0
- package/dist/server/src/controllers/utils.d.ts +16 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +267 -0
- package/dist/server/src/middlewares/index.d.ts +2 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin/index.d.ts +22 -0
- package/dist/server/src/routes/index.d.ts +19 -0
- package/dist/server/src/services/id3.d.ts +18 -0
- package/dist/server/src/services/index.d.ts +156 -0
- package/dist/server/src/services/mapping.d.ts +47 -0
- package/dist/server/src/services/radiocult.d.ts +115 -0
- package/dist/server/src/services/records.d.ts +25 -0
- package/dist/server/src/services/settings.d.ts +103 -0
- package/dist/server/src/utils/errors.d.ts +5 -0
- package/dist/server/src/utils/plugin.d.ts +4 -0
- package/logo.png +0 -0
- package/package.json +98 -0
- package/scripts/migrate-from-fields.mjs +78 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
25
|
+
const fs = require("node:fs");
|
|
26
|
+
const path = require("node:path");
|
|
27
|
+
const os = require("node:os");
|
|
28
|
+
const crypto = require("node:crypto");
|
|
29
|
+
const promises = require("node:stream/promises");
|
|
30
|
+
const NodeID3 = require("node-id3");
|
|
31
|
+
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
32
|
+
const fs__default = /* @__PURE__ */ _interopDefault(fs);
|
|
33
|
+
const path__default = /* @__PURE__ */ _interopDefault(path);
|
|
34
|
+
const os__default = /* @__PURE__ */ _interopDefault(os);
|
|
35
|
+
const crypto__default = /* @__PURE__ */ _interopDefault(crypto);
|
|
36
|
+
const NodeID3__default = /* @__PURE__ */ _interopDefault(NodeID3);
|
|
37
|
+
const PLUGIN_ID = "radiocult-uploader";
|
|
38
|
+
const TRACK_RECORD_UID = "plugin::radiocult-uploader.track-record";
|
|
39
|
+
const PLATFORMS = ["mixcloud", "soundcloud"];
|
|
40
|
+
const LINK_SYNC_RULE = "*/10 * * * *";
|
|
41
|
+
const bootstrap = async ({ strapi: strapi2 }) => {
|
|
42
|
+
const plugin = () => strapi2.plugin(PLUGIN_ID);
|
|
43
|
+
await strapi2.admin.services.permission.actionProvider.registerMany([
|
|
44
|
+
{
|
|
45
|
+
section: "plugins",
|
|
46
|
+
displayName: "Access the Radio Cult Uploader settings",
|
|
47
|
+
uid: "settings",
|
|
48
|
+
pluginName: PLUGIN_ID
|
|
49
|
+
}
|
|
50
|
+
]);
|
|
51
|
+
strapi2.cron.add({
|
|
52
|
+
"radiocult-uploader.link-sync": {
|
|
53
|
+
options: { rule: LINK_SYNC_RULE },
|
|
54
|
+
task: async () => {
|
|
55
|
+
try {
|
|
56
|
+
const { updated, pending } = await plugin().service("radiocult").syncLinks();
|
|
57
|
+
if (pending > 0) {
|
|
58
|
+
strapi2.log.info(
|
|
59
|
+
`radiocult-uploader link sync: ${updated} updated, ${pending} pending`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
strapi2.log.warn(`radiocult-uploader link sync failed: ${error}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
strapi2.db.lifecycles.subscribe(async (event) => {
|
|
69
|
+
try {
|
|
70
|
+
if (event.action !== "beforeUpdate" && event.action !== "afterUpdate") return;
|
|
71
|
+
const mapping2 = await plugin().service("mapping").mapping();
|
|
72
|
+
if (!mapping2.contentType || !mapping2.title) return;
|
|
73
|
+
if (event.model?.uid !== mapping2.contentType) return;
|
|
74
|
+
if (event.action === "beforeUpdate") {
|
|
75
|
+
if (typeof event.params?.data?.[mapping2.title] !== "string") return;
|
|
76
|
+
const row = await strapi2.db.query(mapping2.contentType).findOne({ where: event.params.where });
|
|
77
|
+
event.state.radiocultPreviousTitle = row?.[mapping2.title] ?? null;
|
|
78
|
+
}
|
|
79
|
+
if (event.action === "afterUpdate") {
|
|
80
|
+
await plugin().service("radiocult").maybeSyncTitle(event);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
strapi2.log.warn(`radiocult-uploader title sync failed: ${error}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const destroy = ({ strapi: strapi2 }) => {
|
|
88
|
+
};
|
|
89
|
+
const register = ({ strapi: strapi2 }) => {
|
|
90
|
+
if (typeof fs__default.default.openAsBlob !== "function") {
|
|
91
|
+
strapi2.log.warn(
|
|
92
|
+
"radiocult-uploader: this Node version has no fs.openAsBlob — uploads need Node 20 or newer"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const config = {
|
|
97
|
+
default: ({ env }) => ({
|
|
98
|
+
apiKey: env("RADIOCULT_API_KEY"),
|
|
99
|
+
stationId: env("RADIOCULT_STATION_ID"),
|
|
100
|
+
enabled: env("RADIOCULT_ENABLED")
|
|
101
|
+
}),
|
|
102
|
+
validator() {
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const kind = "collectionType";
|
|
106
|
+
const collectionName = "radiocult_track_records";
|
|
107
|
+
const info = { "singularName": "track-record", "pluralName": "track-records", "displayName": "Radio Cult Track Record", "description": "Bookkeeping for tracks uploaded to Radio Cult (managed by the Radio Cult Uploader plugin)" };
|
|
108
|
+
const options = { "draftAndPublish": false };
|
|
109
|
+
const pluginOptions = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
|
|
110
|
+
const attributes = { "contentTypeUid": { "type": "string", "required": true }, "entryDocumentId": { "type": "string", "required": true }, "trackId": { "type": "string" }, "uploadedAt": { "type": "datetime" }, "mixcloudRequestedAt": { "type": "datetime" }, "soundcloudRequestedAt": { "type": "datetime" }, "lastSyncedAt": { "type": "datetime" } };
|
|
111
|
+
const schema = {
|
|
112
|
+
kind,
|
|
113
|
+
collectionName,
|
|
114
|
+
info,
|
|
115
|
+
options,
|
|
116
|
+
pluginOptions,
|
|
117
|
+
attributes
|
|
118
|
+
};
|
|
119
|
+
const contentTypes = {
|
|
120
|
+
"track-record": { schema }
|
|
121
|
+
};
|
|
122
|
+
class RcHttpError extends Error {
|
|
123
|
+
constructor(status, message) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.status = status;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const respond = (fn) => async (ctx) => {
|
|
129
|
+
try {
|
|
130
|
+
ctx.body = await fn(ctx);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const status = error instanceof RcHttpError ? error.status : 500;
|
|
133
|
+
const admin2 = ctx.state?.user?.email ?? ctx.state?.user?.id ?? "unknown admin";
|
|
134
|
+
const line = `radiocult-uploader ${ctx.method} ${ctx.path} (${admin2}) → ${status}: ${error?.message ?? error}`;
|
|
135
|
+
if (status >= 500) strapi.log.error(`${line}
|
|
136
|
+
${error?.stack ?? ""}`);
|
|
137
|
+
else strapi.log.warn(line);
|
|
138
|
+
ctx.status = status;
|
|
139
|
+
ctx.body = { error: error?.message ?? "Unexpected error" };
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const metaFrom = (body) => {
|
|
143
|
+
let tags = body?.tags;
|
|
144
|
+
if (typeof tags === "string") {
|
|
145
|
+
try {
|
|
146
|
+
tags = JSON.parse(tags);
|
|
147
|
+
} catch {
|
|
148
|
+
tags = tags.split(",");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
title: body?.title,
|
|
153
|
+
artist: body?.artist,
|
|
154
|
+
album: body?.album,
|
|
155
|
+
description: body?.description,
|
|
156
|
+
tags
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
const uploader = ({ strapi: strapi2 }) => {
|
|
160
|
+
const service = () => strapi2.plugin(PLUGIN_ID).service("radiocult");
|
|
161
|
+
return {
|
|
162
|
+
upload: respond(async (ctx) => {
|
|
163
|
+
const filesField = ctx.request.files?.files;
|
|
164
|
+
const file = Array.isArray(filesField) ? filesField[0] : filesField;
|
|
165
|
+
return service().uploadTrack({
|
|
166
|
+
documentId: ctx.params.documentId,
|
|
167
|
+
file,
|
|
168
|
+
force: ctx.query.force === "true",
|
|
169
|
+
meta: metaFrom(ctx.request.body)
|
|
170
|
+
});
|
|
171
|
+
}),
|
|
172
|
+
publish: respond(
|
|
173
|
+
async (ctx) => service().publish({
|
|
174
|
+
documentId: ctx.params.documentId,
|
|
175
|
+
platform: ctx.params.platform,
|
|
176
|
+
force: ctx.query.force === "true",
|
|
177
|
+
meta: metaFrom(ctx.request.body)
|
|
178
|
+
})
|
|
179
|
+
),
|
|
180
|
+
updateTrack: respond(
|
|
181
|
+
async (ctx) => service().updateTrackMetadata({
|
|
182
|
+
documentId: ctx.params.documentId,
|
|
183
|
+
meta: metaFrom(ctx.request.body)
|
|
184
|
+
})
|
|
185
|
+
),
|
|
186
|
+
status: respond(
|
|
187
|
+
async (ctx) => service().getStatus(ctx.params.documentId, { refresh: ctx.query.refresh === "true" })
|
|
188
|
+
)
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
const bodyMaxFileSize = (strapi2) => {
|
|
192
|
+
try {
|
|
193
|
+
const middlewares2 = strapi2.config.get("middlewares", []);
|
|
194
|
+
const entry = middlewares2.find(
|
|
195
|
+
(m) => m === "strapi::body" || m && typeof m === "object" && m.name === "strapi::body"
|
|
196
|
+
);
|
|
197
|
+
if (!entry || typeof entry === "string") return 200 * 1024 * 1024;
|
|
198
|
+
return entry.config?.formidable?.maxFileSize ?? 200 * 1024 * 1024;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const settings$1 = ({ strapi: strapi2 }) => {
|
|
204
|
+
const settings2 = () => strapi2.plugin(PLUGIN_ID).service("settings");
|
|
205
|
+
const mapping2 = () => strapi2.plugin(PLUGIN_ID).service("mapping");
|
|
206
|
+
return {
|
|
207
|
+
get: respond(async () => {
|
|
208
|
+
const value = await settings2().get();
|
|
209
|
+
const { apiKey, ...credentials } = value.credentials;
|
|
210
|
+
const effective = await settings2().credentials();
|
|
211
|
+
return {
|
|
212
|
+
...value,
|
|
213
|
+
credentials: { ...credentials, apiKeySet: !!effective.apiKey },
|
|
214
|
+
envLocks: settings2().envLocks()
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
217
|
+
update: respond(async (ctx) => {
|
|
218
|
+
const patch = ctx.request.body ?? {};
|
|
219
|
+
if (patch.credentials && !patch.credentials.apiKey) delete patch.credentials.apiKey;
|
|
220
|
+
const value = await settings2().update(patch);
|
|
221
|
+
const { apiKey, ...credentials } = value.credentials;
|
|
222
|
+
const effective = await settings2().credentials();
|
|
223
|
+
return {
|
|
224
|
+
...value,
|
|
225
|
+
credentials: { ...credentials, apiKeySet: !!effective.apiKey },
|
|
226
|
+
envLocks: settings2().envLocks()
|
|
227
|
+
};
|
|
228
|
+
}),
|
|
229
|
+
testConnection: respond(async () => settings2().testConnection()),
|
|
230
|
+
/**
|
|
231
|
+
* Theme + mapped content type — readable by any admin (the uploader page
|
|
232
|
+
* needs the theme, the edit-view side panel needs the content type).
|
|
233
|
+
*/
|
|
234
|
+
uiConfig: respond(async () => {
|
|
235
|
+
const value = await settings2().get();
|
|
236
|
+
return { theme: value.theme, contentType: value.mapping.contentType };
|
|
237
|
+
}),
|
|
238
|
+
contentTypes: respond(async () => ({ contentTypes: mapping2().listContentTypes() })),
|
|
239
|
+
diagnostics: respond(async () => {
|
|
240
|
+
const fs2 = await import("node:fs");
|
|
241
|
+
const maxFileSize = bodyMaxFileSize(strapi2);
|
|
242
|
+
return {
|
|
243
|
+
nodeVersion: process.version,
|
|
244
|
+
openAsBlob: typeof fs2.openAsBlob === "function",
|
|
245
|
+
bodyMaxFileSize: maxFileSize,
|
|
246
|
+
// Radio Cult accepts files up to 750MB; the app's body middleware has
|
|
247
|
+
// to allow at least that or big uploads die at the Strapi door.
|
|
248
|
+
bodyLimitOk: maxFileSize === null ? null : maxFileSize >= 750 * 1024 * 1024,
|
|
249
|
+
cronEnabled: strapi2.config.get("server.cron.enabled", true)
|
|
250
|
+
};
|
|
251
|
+
})
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
const controllers = {
|
|
255
|
+
uploader,
|
|
256
|
+
settings: settings$1
|
|
257
|
+
};
|
|
258
|
+
const middlewares = {};
|
|
259
|
+
const policies = {};
|
|
260
|
+
const settingsPolicy = [
|
|
261
|
+
{
|
|
262
|
+
name: "admin::hasPermissions",
|
|
263
|
+
config: { actions: ["plugin::radiocult-uploader.settings"] }
|
|
264
|
+
}
|
|
265
|
+
];
|
|
266
|
+
const admin = {
|
|
267
|
+
type: "admin",
|
|
268
|
+
routes: [
|
|
269
|
+
{
|
|
270
|
+
method: "POST",
|
|
271
|
+
path: "/entries/:documentId/upload",
|
|
272
|
+
handler: "uploader.upload",
|
|
273
|
+
config: { policies: [] }
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
method: "POST",
|
|
277
|
+
path: "/entries/:documentId/publish/:platform",
|
|
278
|
+
handler: "uploader.publish",
|
|
279
|
+
config: { policies: [] }
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
method: "PUT",
|
|
283
|
+
path: "/entries/:documentId/track",
|
|
284
|
+
handler: "uploader.updateTrack",
|
|
285
|
+
config: { policies: [] }
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
method: "GET",
|
|
289
|
+
path: "/entries/:documentId/status",
|
|
290
|
+
handler: "uploader.status",
|
|
291
|
+
config: { policies: [] }
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
method: "GET",
|
|
295
|
+
path: "/settings",
|
|
296
|
+
handler: "settings.get",
|
|
297
|
+
config: { policies: settingsPolicy }
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
method: "PUT",
|
|
301
|
+
path: "/settings",
|
|
302
|
+
handler: "settings.update",
|
|
303
|
+
config: { policies: settingsPolicy }
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
method: "POST",
|
|
307
|
+
path: "/settings/test-connection",
|
|
308
|
+
handler: "settings.testConnection",
|
|
309
|
+
config: { policies: settingsPolicy }
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
method: "GET",
|
|
313
|
+
path: "/content-types",
|
|
314
|
+
handler: "settings.contentTypes",
|
|
315
|
+
config: { policies: settingsPolicy }
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
method: "GET",
|
|
319
|
+
path: "/diagnostics",
|
|
320
|
+
handler: "settings.diagnostics",
|
|
321
|
+
config: { policies: settingsPolicy }
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
method: "GET",
|
|
325
|
+
path: "/ui-config",
|
|
326
|
+
handler: "settings.uiConfig",
|
|
327
|
+
config: { policies: [] }
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
};
|
|
331
|
+
const routes = {
|
|
332
|
+
admin
|
|
333
|
+
};
|
|
334
|
+
const DEFAULT_SETTINGS = {
|
|
335
|
+
credentials: { apiKey: "", stationId: "", enabled: true },
|
|
336
|
+
mapping: {
|
|
337
|
+
contentType: null,
|
|
338
|
+
title: null,
|
|
339
|
+
image: null,
|
|
340
|
+
broadcastDate: null,
|
|
341
|
+
soundcloudLink: null,
|
|
342
|
+
mixcloudLink: null,
|
|
343
|
+
tagsRelation: null,
|
|
344
|
+
tagsLabelField: null,
|
|
345
|
+
showRelation: null,
|
|
346
|
+
showLabelField: null
|
|
347
|
+
},
|
|
348
|
+
defaults: { artist: "", description: "" },
|
|
349
|
+
theme: { preset: "paper", overrides: {}, fontFamily: "system", logoUrl: null, stationName: "" }
|
|
350
|
+
};
|
|
351
|
+
const deepMerge = (base, patch) => {
|
|
352
|
+
const out = { ...base };
|
|
353
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
354
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof base[key] === "object" && base[key] !== null) {
|
|
355
|
+
out[key] = deepMerge(base[key], value);
|
|
356
|
+
} else if (value !== void 0) {
|
|
357
|
+
out[key] = value;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
};
|
|
362
|
+
const STRING_TYPES = ["string", "text"];
|
|
363
|
+
const settings = ({ strapi: strapi2 }) => ({
|
|
364
|
+
store() {
|
|
365
|
+
return strapi2.store({ type: "plugin", name: PLUGIN_ID });
|
|
366
|
+
},
|
|
367
|
+
/** Full settings object, stored values merged over defaults. */
|
|
368
|
+
async get() {
|
|
369
|
+
const stored = await this.store().get({ key: "settings" });
|
|
370
|
+
return deepMerge(DEFAULT_SETTINGS, stored ?? {});
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* Which credential fields are locked by plugin config / env vars. The
|
|
374
|
+
* settings page greys those inputs out and says where the value comes from.
|
|
375
|
+
*/
|
|
376
|
+
envLocks() {
|
|
377
|
+
const config2 = strapi2.config.get(`plugin::${PLUGIN_ID}`, {});
|
|
378
|
+
return {
|
|
379
|
+
apiKey: !!config2.apiKey,
|
|
380
|
+
stationId: !!config2.stationId,
|
|
381
|
+
enabled: config2.enabled !== void 0 && config2.enabled !== null
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
/** Effective credentials: plugin config (env-backed) wins over the store. */
|
|
385
|
+
async credentials() {
|
|
386
|
+
const config2 = strapi2.config.get(`plugin::${PLUGIN_ID}`, {});
|
|
387
|
+
const stored = (await this.get()).credentials;
|
|
388
|
+
return {
|
|
389
|
+
apiKey: config2.apiKey || stored.apiKey,
|
|
390
|
+
stationId: config2.stationId || stored.stationId,
|
|
391
|
+
enabled: config2.enabled !== void 0 && config2.enabled !== null ? config2.enabled !== false && config2.enabled !== "false" : stored.enabled
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
/** Validates a mapping against the live content types. Throws RcHttpError(400). */
|
|
395
|
+
validateMapping(mapping2) {
|
|
396
|
+
if (!mapping2.contentType) return;
|
|
397
|
+
const model = strapi2.contentTypes[mapping2.contentType];
|
|
398
|
+
if (!model) throw new RcHttpError(400, `Unknown content type "${mapping2.contentType}"`);
|
|
399
|
+
const attrs = model.attributes ?? {};
|
|
400
|
+
const expect = (field, kinds, label, required = false) => {
|
|
401
|
+
if (!field) {
|
|
402
|
+
if (required) throw new RcHttpError(400, `The ${label} field must be mapped`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const attr = attrs[field];
|
|
406
|
+
if (!attr) {
|
|
407
|
+
throw new RcHttpError(400, `"${field}" (${label}) does not exist on ${mapping2.contentType}`);
|
|
408
|
+
}
|
|
409
|
+
if (!kinds.includes(attr.type)) {
|
|
410
|
+
throw new RcHttpError(
|
|
411
|
+
400,
|
|
412
|
+
`"${field}" (${label}) must be a ${kinds.join("/")} field, not ${attr.type}`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
expect(mapping2.title, STRING_TYPES, "title", true);
|
|
417
|
+
expect(mapping2.image, ["media"], "image");
|
|
418
|
+
expect(mapping2.broadcastDate, ["date", "datetime"], "broadcast date");
|
|
419
|
+
expect(mapping2.soundcloudLink, STRING_TYPES, "SoundCloud link");
|
|
420
|
+
expect(mapping2.mixcloudLink, STRING_TYPES, "Mixcloud link");
|
|
421
|
+
expect(mapping2.tagsRelation, ["relation"], "tags relation");
|
|
422
|
+
expect(mapping2.showRelation, ["relation"], "show relation");
|
|
423
|
+
const expectTargetField = (relationField, labelField, label) => {
|
|
424
|
+
if (!relationField || !labelField) return;
|
|
425
|
+
const target = attrs[relationField]?.target;
|
|
426
|
+
const targetModel = target ? strapi2.contentTypes[target] : null;
|
|
427
|
+
const targetAttr = targetModel?.attributes?.[labelField];
|
|
428
|
+
if (!targetAttr || !STRING_TYPES.includes(targetAttr.type)) {
|
|
429
|
+
throw new RcHttpError(
|
|
430
|
+
400,
|
|
431
|
+
`"${labelField}" (${label}) must be a string field on ${target ?? "the related type"}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
expectTargetField(mapping2.tagsRelation, mapping2.tagsLabelField, "tag label");
|
|
436
|
+
expectTargetField(mapping2.showRelation, mapping2.showLabelField, "show name");
|
|
437
|
+
},
|
|
438
|
+
/** Merge-updates the stored settings. Returns the new effective settings. */
|
|
439
|
+
async update(patch) {
|
|
440
|
+
const current = await this.get();
|
|
441
|
+
const next = deepMerge(current, patch ?? {});
|
|
442
|
+
if (patch?.theme?.overrides) next.theme.overrides = patch.theme.overrides;
|
|
443
|
+
this.validateMapping(next.mapping);
|
|
444
|
+
await this.store().set({ key: "settings", value: next });
|
|
445
|
+
return next;
|
|
446
|
+
},
|
|
447
|
+
/** Cheap authenticated Radio Cult call to prove the key + station work. */
|
|
448
|
+
async testConnection() {
|
|
449
|
+
const { apiKey, stationId } = await this.credentials();
|
|
450
|
+
if (!apiKey || !stationId) {
|
|
451
|
+
return { ok: false, message: "Enter an API key and station ID first" };
|
|
452
|
+
}
|
|
453
|
+
let res;
|
|
454
|
+
try {
|
|
455
|
+
res = await fetch(`https://api.radiocult.fm/api/station/${stationId}/media/track`, {
|
|
456
|
+
headers: { "x-api-key": apiKey },
|
|
457
|
+
signal: AbortSignal.timeout(15e3)
|
|
458
|
+
});
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { ok: false, message: `Could not reach Radio Cult: ${error?.message ?? error}` };
|
|
461
|
+
}
|
|
462
|
+
if (res.status === 401 || res.status === 403) {
|
|
463
|
+
return { ok: false, message: "Radio Cult rejected the API key" };
|
|
464
|
+
}
|
|
465
|
+
if (res.status === 404) {
|
|
466
|
+
return { ok: false, message: "Station not found — check the station ID" };
|
|
467
|
+
}
|
|
468
|
+
if (!res.ok) {
|
|
469
|
+
return { ok: false, message: `Radio Cult responded ${res.status}` };
|
|
470
|
+
}
|
|
471
|
+
return { ok: true, message: "Connected to Radio Cult ✓" };
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
const LINK_KEY = {
|
|
475
|
+
soundcloud: "soundcloudLink",
|
|
476
|
+
mixcloud: "mixcloudLink"
|
|
477
|
+
};
|
|
478
|
+
const mapping = ({ strapi: strapi2 }) => ({
|
|
479
|
+
settings() {
|
|
480
|
+
return strapi2.plugin(PLUGIN_ID).service("settings");
|
|
481
|
+
},
|
|
482
|
+
async mapping() {
|
|
483
|
+
return (await this.settings().get()).mapping;
|
|
484
|
+
},
|
|
485
|
+
/** Throws 503 until a content type + title field have been mapped. */
|
|
486
|
+
async requireMapping() {
|
|
487
|
+
const mapping2 = await this.mapping();
|
|
488
|
+
if (!mapping2.contentType || !mapping2.title) {
|
|
489
|
+
throw new RcHttpError(
|
|
490
|
+
503,
|
|
491
|
+
"The uploader is not set up yet — map your episode content type in Settings → Radio Cult Uploader"
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (!strapi2.contentTypes[mapping2.contentType]) {
|
|
495
|
+
throw new RcHttpError(
|
|
496
|
+
503,
|
|
497
|
+
`The mapped content type "${mapping2.contentType}" no longer exists — update the plugin settings`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return mapping2;
|
|
501
|
+
},
|
|
502
|
+
/** Populate object covering every mapped relation/media field. */
|
|
503
|
+
populateFor(mapping2) {
|
|
504
|
+
const populate = {};
|
|
505
|
+
for (const field of [mapping2.image, mapping2.tagsRelation, mapping2.showRelation]) {
|
|
506
|
+
if (field) populate[field] = true;
|
|
507
|
+
}
|
|
508
|
+
return populate;
|
|
509
|
+
},
|
|
510
|
+
normalise(mapping2, entry) {
|
|
511
|
+
const tagsRaw = mapping2.tagsRelation ? entry[mapping2.tagsRelation] : null;
|
|
512
|
+
const tagEntries = Array.isArray(tagsRaw) ? tagsRaw : tagsRaw ? [tagsRaw] : [];
|
|
513
|
+
const tags = mapping2.tagsLabelField ? tagEntries.map((tag) => tag?.[mapping2.tagsLabelField]).filter(Boolean) : [];
|
|
514
|
+
const showRaw = mapping2.showRelation ? entry[mapping2.showRelation] : null;
|
|
515
|
+
const show = Array.isArray(showRaw) ? showRaw[0] : showRaw;
|
|
516
|
+
return {
|
|
517
|
+
documentId: entry.documentId,
|
|
518
|
+
title: mapping2.title && entry[mapping2.title] || "",
|
|
519
|
+
image: mapping2.image && entry[mapping2.image] || null,
|
|
520
|
+
broadcastDate: mapping2.broadcastDate && entry[mapping2.broadcastDate] || null,
|
|
521
|
+
tags,
|
|
522
|
+
showName: mapping2.showLabelField && show?.[mapping2.showLabelField] || "",
|
|
523
|
+
links: {
|
|
524
|
+
soundcloud: mapping2.soundcloudLink && entry[mapping2.soundcloudLink] || null,
|
|
525
|
+
mixcloud: mapping2.mixcloudLink && entry[mapping2.mixcloudLink] || null
|
|
526
|
+
},
|
|
527
|
+
raw: entry
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
/** Loads one mapped entry (with relations populated) or throws 404. */
|
|
531
|
+
async loadEntry(documentId) {
|
|
532
|
+
const mapping2 = await this.requireMapping();
|
|
533
|
+
const entry = await strapi2.documents(mapping2.contentType).findOne({
|
|
534
|
+
documentId,
|
|
535
|
+
populate: this.populateFor(mapping2)
|
|
536
|
+
});
|
|
537
|
+
if (!entry) throw new RcHttpError(404, "Entry not found");
|
|
538
|
+
return this.normalise(mapping2, entry);
|
|
539
|
+
},
|
|
540
|
+
/**
|
|
541
|
+
* Writes platform links back onto the user's entry. Only the mapped link
|
|
542
|
+
* fields are touched. Uses the db layer (like the pre-plugin code) so the
|
|
543
|
+
* write lands directly on the stored row without a draft/publish cycle.
|
|
544
|
+
*/
|
|
545
|
+
async writeLinks(documentId, links) {
|
|
546
|
+
const mapping2 = await this.requireMapping();
|
|
547
|
+
const data = {};
|
|
548
|
+
for (const platform of PLATFORMS) {
|
|
549
|
+
const url = links[platform];
|
|
550
|
+
const field = mapping2[LINK_KEY[platform]];
|
|
551
|
+
if (url && field) data[field] = url;
|
|
552
|
+
}
|
|
553
|
+
if (Object.keys(data).length === 0) return [];
|
|
554
|
+
await strapi2.db.query(mapping2.contentType).update({ where: { documentId }, data });
|
|
555
|
+
return Object.keys(data);
|
|
556
|
+
},
|
|
557
|
+
/**
|
|
558
|
+
* Content types + attributes for the settings page's mapping selects.
|
|
559
|
+
* Only user-created (api::) types; attributes reduced to what the UI needs.
|
|
560
|
+
*/
|
|
561
|
+
listContentTypes() {
|
|
562
|
+
return Object.entries(strapi2.contentTypes).filter(([uid]) => uid.startsWith("api::")).map(([uid, model]) => ({
|
|
563
|
+
uid,
|
|
564
|
+
displayName: model.info?.displayName ?? uid,
|
|
565
|
+
attributes: Object.entries(model.attributes ?? {}).map(([name, attr]) => ({
|
|
566
|
+
name,
|
|
567
|
+
type: attr.type,
|
|
568
|
+
target: attr.target ?? null,
|
|
569
|
+
targetAttributes: attr.type === "relation" && attr.target ? Object.entries(
|
|
570
|
+
strapi2.contentTypes[attr.target]?.attributes ?? {}
|
|
571
|
+
).filter(
|
|
572
|
+
([, targetAttr]) => ["string", "text"].includes(targetAttr.type)
|
|
573
|
+
).map(([targetName]) => targetName) : null
|
|
574
|
+
}))
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
const REQUESTED_AT = {
|
|
579
|
+
mixcloud: "mixcloudRequestedAt",
|
|
580
|
+
soundcloud: "soundcloudRequestedAt"
|
|
581
|
+
};
|
|
582
|
+
const records = ({ strapi: strapi2 }) => ({
|
|
583
|
+
/** The record for one entry, or null. */
|
|
584
|
+
async find(contentTypeUid, entryDocumentId) {
|
|
585
|
+
return await strapi2.db.query(TRACK_RECORD_UID).findOne({ where: { contentTypeUid, entryDocumentId } });
|
|
586
|
+
},
|
|
587
|
+
/** Creates or updates the record for one entry (service-level uniqueness). */
|
|
588
|
+
async upsert(contentTypeUid, entryDocumentId, data) {
|
|
589
|
+
const existing = await this.find(contentTypeUid, entryDocumentId);
|
|
590
|
+
if (existing) {
|
|
591
|
+
return await strapi2.db.query(TRACK_RECORD_UID).update({ where: { id: existing.id }, data });
|
|
592
|
+
}
|
|
593
|
+
return await strapi2.db.query(TRACK_RECORD_UID).create({ data: { contentTypeUid, entryDocumentId, ...data } });
|
|
594
|
+
},
|
|
595
|
+
/** All records with an uploaded track for the given content type. */
|
|
596
|
+
async findUploaded(contentTypeUid, entryDocumentIds) {
|
|
597
|
+
return await strapi2.db.query(TRACK_RECORD_UID).findMany({
|
|
598
|
+
where: {
|
|
599
|
+
contentTypeUid,
|
|
600
|
+
trackId: { $notNull: true },
|
|
601
|
+
...entryDocumentIds ? { entryDocumentId: { $in: entryDocumentIds } } : {}
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
async function existingTagLength(filePath) {
|
|
607
|
+
const handle = await fs__default.default.promises.open(filePath, "r");
|
|
608
|
+
try {
|
|
609
|
+
const header = Buffer.alloc(10);
|
|
610
|
+
const { bytesRead } = await handle.read(header, 0, 10, 0);
|
|
611
|
+
if (bytesRead < 10 || header.toString("latin1", 0, 3) !== "ID3") return 0;
|
|
612
|
+
const size = (header[6] & 127) << 21 | (header[7] & 127) << 14 | (header[8] & 127) << 7 | header[9] & 127;
|
|
613
|
+
const hasFooter = (header[5] & 16) !== 0;
|
|
614
|
+
return 10 + size + (hasFooter ? 10 : 0);
|
|
615
|
+
} finally {
|
|
616
|
+
await handle.close();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function embedArtworkMp3(srcPath, opts) {
|
|
620
|
+
const tagBuffer = NodeID3__default.default.create({
|
|
621
|
+
title: opts.title,
|
|
622
|
+
artist: opts.artist,
|
|
623
|
+
album: opts.album,
|
|
624
|
+
image: {
|
|
625
|
+
mime: opts.imageMime,
|
|
626
|
+
type: { id: 3, name: "front cover" },
|
|
627
|
+
description: "cover",
|
|
628
|
+
imageBuffer: opts.imageBuffer
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
const audioStart = await existingTagLength(srcPath);
|
|
632
|
+
const outPath = path__default.default.join(
|
|
633
|
+
os__default.default.tmpdir(),
|
|
634
|
+
`radiocult-${Date.now()}-${crypto__default.default.randomBytes(4).toString("hex")}.mp3`
|
|
635
|
+
);
|
|
636
|
+
await fs__default.default.promises.writeFile(outPath, tagBuffer);
|
|
637
|
+
await promises.pipeline(
|
|
638
|
+
fs__default.default.createReadStream(srcPath, { start: audioStart }),
|
|
639
|
+
fs__default.default.createWriteStream(outPath, { flags: "a" })
|
|
640
|
+
);
|
|
641
|
+
return outPath;
|
|
642
|
+
}
|
|
643
|
+
const RC_BASE = "https://api.radiocult.fm";
|
|
644
|
+
const MAX_TRACK_BYTES = 750 * 1024 * 1024;
|
|
645
|
+
const MAX_PUBLISH_ARTWORK_BYTES = 2 * 1024 * 1024;
|
|
646
|
+
const MAX_EMBED_ARTWORK_BYTES = 10 * 1024 * 1024;
|
|
647
|
+
const PUBLISH_RETRY_GUARD_MS = 10 * 60 * 1e3;
|
|
648
|
+
const STALLED_AFTER_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
649
|
+
const TRACK_LIST_CACHE_MS = 20 * 1e3;
|
|
650
|
+
const RC_URL_FIELD = {
|
|
651
|
+
mixcloud: "mixcloudUrl",
|
|
652
|
+
soundcloud: "soundcloudUrl"
|
|
653
|
+
};
|
|
654
|
+
const albumFor = (broadcastDate) => new Date(broadcastDate ?? Date.now()).toLocaleDateString("en-GB", {
|
|
655
|
+
month: "long",
|
|
656
|
+
year: "numeric"
|
|
657
|
+
});
|
|
658
|
+
let rateLimitedUntil = 0;
|
|
659
|
+
let trackListCache = null;
|
|
660
|
+
const cleanString = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
661
|
+
function platformState(entry, record, platform) {
|
|
662
|
+
const link = entry.links[platform];
|
|
663
|
+
const requestedAt = record?.[REQUESTED_AT[platform]] ?? null;
|
|
664
|
+
let state = "not_published";
|
|
665
|
+
if (link) state = "linked";
|
|
666
|
+
else if (requestedAt) {
|
|
667
|
+
state = Date.now() - new Date(requestedAt).getTime() > STALLED_AFTER_MS ? "stalled" : "publishing";
|
|
668
|
+
}
|
|
669
|
+
return { state, requestedAt, link: link ?? null };
|
|
670
|
+
}
|
|
671
|
+
function awaitingLink(entry, record, platform) {
|
|
672
|
+
if (entry.links[platform]) return false;
|
|
673
|
+
const { state } = platformState(entry, record, platform);
|
|
674
|
+
if (state === "publishing") return true;
|
|
675
|
+
if (state !== "not_published") return false;
|
|
676
|
+
const uploadedAt = record?.uploadedAt;
|
|
677
|
+
return !!uploadedAt && Date.now() - new Date(uploadedAt).getTime() < STALLED_AFTER_MS;
|
|
678
|
+
}
|
|
679
|
+
const radiocult = ({ strapi: strapi2 }) => {
|
|
680
|
+
const settings2 = () => strapi2.plugin(PLUGIN_ID).service("settings");
|
|
681
|
+
const mapping2 = () => strapi2.plugin(PLUGIN_ID).service("mapping");
|
|
682
|
+
const records2 = () => strapi2.plugin(PLUGIN_ID).service("records");
|
|
683
|
+
const assertConfigured = async () => {
|
|
684
|
+
const { apiKey, stationId, enabled } = await settings2().credentials();
|
|
685
|
+
if (!enabled) {
|
|
686
|
+
throw new RcHttpError(503, "Radio Cult uploads are switched off in the plugin settings");
|
|
687
|
+
}
|
|
688
|
+
if (!apiKey || !stationId) {
|
|
689
|
+
throw new RcHttpError(
|
|
690
|
+
503,
|
|
691
|
+
"Radio Cult is not configured — set the API key and station ID in Settings → Radio Cult Uploader"
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
return { apiKey, stationId };
|
|
695
|
+
};
|
|
696
|
+
const isUsable = async () => {
|
|
697
|
+
const { apiKey, stationId, enabled } = await settings2().credentials();
|
|
698
|
+
return enabled && !!apiKey && !!stationId;
|
|
699
|
+
};
|
|
700
|
+
async function rcFetch(pathname, init = {}, timeoutMs) {
|
|
701
|
+
const { apiKey, stationId } = await assertConfigured();
|
|
702
|
+
const method = init.method ?? "GET";
|
|
703
|
+
if (Date.now() < rateLimitedUntil) {
|
|
704
|
+
strapi2.log.warn(
|
|
705
|
+
`radiocult api: ${method} ${pathname} skipped — rate limited until ${new Date(rateLimitedUntil).toISOString()}`
|
|
706
|
+
);
|
|
707
|
+
throw new RcHttpError(
|
|
708
|
+
429,
|
|
709
|
+
`Radio Cult rate limit hit — try again after ${new Date(rateLimitedUntil).toLocaleTimeString("en-GB")}`
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
let res;
|
|
713
|
+
try {
|
|
714
|
+
res = await fetch(`${RC_BASE}/api/station/${stationId}${pathname}`, {
|
|
715
|
+
...init,
|
|
716
|
+
headers: { "x-api-key": apiKey, ...init.headers ?? {} },
|
|
717
|
+
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0
|
|
718
|
+
});
|
|
719
|
+
} catch (error) {
|
|
720
|
+
strapi2.log.error(
|
|
721
|
+
`radiocult api: ${method} ${pathname} network failure: ${error?.stack ?? error}`
|
|
722
|
+
);
|
|
723
|
+
throw new RcHttpError(502, `Could not reach Radio Cult: ${error?.message ?? error}`);
|
|
724
|
+
}
|
|
725
|
+
if (res.status === 429) {
|
|
726
|
+
const retryAfter = Number(res.headers.get("retry-after")) || 60;
|
|
727
|
+
rateLimitedUntil = Date.now() + retryAfter * 1e3;
|
|
728
|
+
strapi2.log.warn(
|
|
729
|
+
`radiocult api: ${method} ${pathname} → 429, backing off ${retryAfter}s (until ${new Date(rateLimitedUntil).toISOString()})`
|
|
730
|
+
);
|
|
731
|
+
throw new RcHttpError(
|
|
732
|
+
429,
|
|
733
|
+
`Radio Cult rate limit hit — try again after ${new Date(rateLimitedUntil).toLocaleTimeString("en-GB")}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (!res.ok) {
|
|
737
|
+
const body = await res.text().catch(() => "");
|
|
738
|
+
strapi2.log.error(
|
|
739
|
+
`radiocult api: ${method} ${pathname} → ${res.status}: ${body.slice(0, 1e3)}`
|
|
740
|
+
);
|
|
741
|
+
if (res.status === 402) {
|
|
742
|
+
const isMixcloudPublish = pathname.endsWith("/upload/mixcloud");
|
|
743
|
+
throw new RcHttpError(
|
|
744
|
+
402,
|
|
745
|
+
isMixcloudPublish ? `Radio Cult refused the Mixcloud publish with 402: ${body.slice(0, 200)}. If your plan covers third-party publishing, this is a known fault on Radio Cult's side — publish this one by hand in the Radio Cult dashboard (Media → the track → Actions → Upload to Mixcloud); the link will still be saved to the entry automatically. Otherwise, third-party publishing needs Radio Cult Premium/Business or the Connect add-on.` : `Radio Cult refused with 402: ${body.slice(0, 200)}. Per Radio Cult's docs this means the station's plan doesn't cover this endpoint — third-party publishing needs Premium/Business or the Connect add-on.`
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
throw new RcHttpError(
|
|
749
|
+
res.status >= 500 ? 502 : res.status,
|
|
750
|
+
`Radio Cult responded ${res.status}: ${body.slice(0, 300)}`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
strapi2.log.debug(`radiocult api: ${method} ${pathname} → ${res.status}`);
|
|
754
|
+
return res;
|
|
755
|
+
}
|
|
756
|
+
async function resolveArtwork(image, maxBytes) {
|
|
757
|
+
if (!image?.url) return null;
|
|
758
|
+
const candidates = [image.formats?.large, image.formats?.medium, image].filter(Boolean);
|
|
759
|
+
for (const candidate of candidates) {
|
|
760
|
+
const mime = candidate.mime ?? image.mime;
|
|
761
|
+
if (mime !== "image/jpeg" && mime !== "image/png") continue;
|
|
762
|
+
if (candidate.size && candidate.size * 1024 > maxBytes) continue;
|
|
763
|
+
try {
|
|
764
|
+
let buffer;
|
|
765
|
+
if (candidate.url.startsWith("http")) {
|
|
766
|
+
const res = await fetch(candidate.url, { signal: AbortSignal.timeout(3e4) });
|
|
767
|
+
if (!res.ok) continue;
|
|
768
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
769
|
+
} else {
|
|
770
|
+
const diskPath = path__default.default.join(strapi2.dirs.static.public, candidate.url);
|
|
771
|
+
try {
|
|
772
|
+
buffer = await fs__default.default.promises.readFile(diskPath);
|
|
773
|
+
} catch {
|
|
774
|
+
const serverUrl = strapi2.config.get("server.url", "http://localhost:1337");
|
|
775
|
+
const res = await fetch(`${serverUrl}${candidate.url}`, {
|
|
776
|
+
signal: AbortSignal.timeout(3e4)
|
|
777
|
+
});
|
|
778
|
+
if (!res.ok) continue;
|
|
779
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (buffer.length > maxBytes) continue;
|
|
783
|
+
return { buffer, mime, name: candidate.name ?? image.name ?? "artwork" };
|
|
784
|
+
} catch {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
function describeImage(image) {
|
|
791
|
+
if (!image?.url) return null;
|
|
792
|
+
const usable = [image.formats?.large, image.formats?.medium, image].filter(Boolean).some((candidate) => {
|
|
793
|
+
const mime = candidate.mime ?? image.mime;
|
|
794
|
+
if (mime !== "image/jpeg" && mime !== "image/png") return false;
|
|
795
|
+
return !candidate.size || candidate.size * 1024 <= MAX_PUBLISH_ARTWORK_BYTES;
|
|
796
|
+
});
|
|
797
|
+
return {
|
|
798
|
+
name: image.name ?? "entry image",
|
|
799
|
+
previewUrl: image.formats?.small?.url ?? image.formats?.thumbnail?.url ?? image.url,
|
|
800
|
+
usable
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
/**
|
|
805
|
+
* Receives the browser upload (a formidable temp file), embeds artwork
|
|
806
|
+
* for mp3s, forwards the file to Radio Cult and stores the returned track
|
|
807
|
+
* id in the plugin's track-record. `meta` carries the (possibly edited)
|
|
808
|
+
* details from the uploader page; anything missing falls back to the
|
|
809
|
+
* entry's own values.
|
|
810
|
+
*/
|
|
811
|
+
async uploadTrack({
|
|
812
|
+
documentId,
|
|
813
|
+
file,
|
|
814
|
+
force,
|
|
815
|
+
meta = {}
|
|
816
|
+
}) {
|
|
817
|
+
await assertConfigured();
|
|
818
|
+
if (typeof fs__default.default.openAsBlob !== "function") {
|
|
819
|
+
throw new RcHttpError(501, "Server Node version too old for uploads (needs Node 20+)");
|
|
820
|
+
}
|
|
821
|
+
if (!file?.filepath) {
|
|
822
|
+
throw new RcHttpError(400, 'No audio file received — send it as the "files" field');
|
|
823
|
+
}
|
|
824
|
+
const filename = file.originalFilename ?? "episode-audio";
|
|
825
|
+
const ext = path__default.default.extname(filename).toLowerCase();
|
|
826
|
+
if (ext !== ".mp3" && ext !== ".m4a") {
|
|
827
|
+
throw new RcHttpError(
|
|
828
|
+
400,
|
|
829
|
+
`Radio Cult only accepts .mp3 or .m4a files (got "${ext || "no extension"}")`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
if (file.size > MAX_TRACK_BYTES) {
|
|
833
|
+
throw new RcHttpError(413, "File is over Radio Cult’s 750MB limit");
|
|
834
|
+
}
|
|
835
|
+
const map = await mapping2().requireMapping();
|
|
836
|
+
const entry = await mapping2().loadEntry(documentId);
|
|
837
|
+
const record = await records2().find(map.contentType, documentId);
|
|
838
|
+
if (record?.trackId && !force) {
|
|
839
|
+
throw new RcHttpError(
|
|
840
|
+
409,
|
|
841
|
+
`This entry already has a Radio Cult track (${record.trackId})`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
const defaults = (await settings2().get()).defaults;
|
|
845
|
+
const title = cleanString(meta.title) ?? entry.title;
|
|
846
|
+
const artist = cleanString(meta.artist) ?? defaults.artist;
|
|
847
|
+
const album = cleanString(meta.album) ?? albumFor(entry.broadcastDate);
|
|
848
|
+
strapi2.log.info(
|
|
849
|
+
`radiocult: uploading "${filename}" (${Math.round(file.size / 1024 / 1024)}MB) for entry ${documentId} — title "${title}", artist "${artist}", album "${album}"${force ? " (replacing existing track)" : ""}`
|
|
850
|
+
);
|
|
851
|
+
const warnings = [];
|
|
852
|
+
let uploadPath = file.filepath;
|
|
853
|
+
let embeddedTmpPath = null;
|
|
854
|
+
if (ext === ".mp3") {
|
|
855
|
+
const artwork = await resolveArtwork(entry.image, MAX_EMBED_ARTWORK_BYTES);
|
|
856
|
+
if (artwork) {
|
|
857
|
+
try {
|
|
858
|
+
embeddedTmpPath = await embedArtworkMp3(file.filepath, {
|
|
859
|
+
title,
|
|
860
|
+
artist,
|
|
861
|
+
album,
|
|
862
|
+
imageBuffer: artwork.buffer,
|
|
863
|
+
imageMime: artwork.mime
|
|
864
|
+
});
|
|
865
|
+
uploadPath = embeddedTmpPath;
|
|
866
|
+
} catch (error) {
|
|
867
|
+
warnings.push("Could not embed the entry image into the mp3 — uploaded without artwork");
|
|
868
|
+
strapi2.log.warn(`radiocult: artwork embed failed for ${documentId}: ${error}`);
|
|
869
|
+
}
|
|
870
|
+
} else {
|
|
871
|
+
warnings.push(
|
|
872
|
+
entry.image ? "Entry image could not be used as artwork (needs to be a jpeg/png under 10MB)" : "No image set on the entry — track uploaded without artwork"
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
warnings.push("m4a file: artwork cannot be embedded, so the Radio Cult track will have no image");
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const formData = new FormData();
|
|
880
|
+
const mime = ext === ".mp3" ? "audio/mpeg" : "audio/mp4";
|
|
881
|
+
formData.append(
|
|
882
|
+
"stationMedia",
|
|
883
|
+
await fs__default.default.openAsBlob(uploadPath, { type: mime }),
|
|
884
|
+
filename
|
|
885
|
+
);
|
|
886
|
+
formData.append("metadata", JSON.stringify({ title, filename, artist, album }));
|
|
887
|
+
const res = await rcFetch("/media/track", { method: "POST", body: formData });
|
|
888
|
+
const body = await res.json().catch(() => ({}));
|
|
889
|
+
const trackId = body?.track?.id;
|
|
890
|
+
if (!body?.success || !trackId) {
|
|
891
|
+
throw new RcHttpError(
|
|
892
|
+
502,
|
|
893
|
+
`Unexpected Radio Cult response: ${JSON.stringify(body).slice(0, 300)}`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
await records2().upsert(map.contentType, documentId, {
|
|
898
|
+
trackId,
|
|
899
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
900
|
+
mixcloudRequestedAt: null,
|
|
901
|
+
soundcloudRequestedAt: null
|
|
902
|
+
});
|
|
903
|
+
} catch (error) {
|
|
904
|
+
strapi2.log.error(
|
|
905
|
+
`radiocult: track ${trackId} uploaded but saving it for entry ${documentId} failed: ${error}`
|
|
906
|
+
);
|
|
907
|
+
throw new RcHttpError(
|
|
908
|
+
500,
|
|
909
|
+
`The track reached Radio Cult (id ${trackId}) but saving the id failed — note the id down, then delete the track in the Radio Cult dashboard and re-upload`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
strapi2.log.info(`radiocult: uploaded track ${trackId} for entry ${documentId}`);
|
|
913
|
+
return { trackId, warnings };
|
|
914
|
+
} finally {
|
|
915
|
+
if (embeddedTmpPath) await fs__default.default.promises.unlink(embeddedTmpPath).catch(() => {
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
/**
|
|
920
|
+
* Asks Radio Cult to publish the entry's track to Mixcloud or SoundCloud.
|
|
921
|
+
* Radio Cult transfers asynchronously; syncLinks picks up the URL later.
|
|
922
|
+
* `meta` carries the (possibly edited) details from the uploader page.
|
|
923
|
+
*/
|
|
924
|
+
async publish({
|
|
925
|
+
documentId,
|
|
926
|
+
platform,
|
|
927
|
+
force,
|
|
928
|
+
meta = {}
|
|
929
|
+
}) {
|
|
930
|
+
await assertConfigured();
|
|
931
|
+
if (!PLATFORMS.includes(platform)) {
|
|
932
|
+
throw new RcHttpError(400, `Unknown platform "${platform}"`);
|
|
933
|
+
}
|
|
934
|
+
const target = platform;
|
|
935
|
+
const map = await mapping2().requireMapping();
|
|
936
|
+
const entry = await mapping2().loadEntry(documentId);
|
|
937
|
+
const record = await records2().find(map.contentType, documentId);
|
|
938
|
+
const trackId = record?.trackId;
|
|
939
|
+
if (!trackId) throw new RcHttpError(409, "Upload the entry to Radio Cult first");
|
|
940
|
+
const existingLink = entry.links[target];
|
|
941
|
+
if (existingLink && !force) {
|
|
942
|
+
throw new RcHttpError(409, `Already published — link saved: ${existingLink}`);
|
|
943
|
+
}
|
|
944
|
+
const requestedAt = record?.[REQUESTED_AT[target]];
|
|
945
|
+
if (requestedAt && !existingLink && Date.now() - new Date(requestedAt).getTime() < PUBLISH_RETRY_GUARD_MS && !force) {
|
|
946
|
+
throw new RcHttpError(
|
|
947
|
+
409,
|
|
948
|
+
`A ${target} publish was requested less than 10 minutes ago — Radio Cult is probably still transferring`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
const defaults = (await settings2().get()).defaults;
|
|
952
|
+
const title = cleanString(meta.title) ?? entry.title;
|
|
953
|
+
const description = cleanString(meta.description) ?? defaults.description;
|
|
954
|
+
const tags = Array.isArray(meta.tags) ? meta.tags.map((tag) => cleanString(tag)).filter((tag) => !!tag) : entry.tags;
|
|
955
|
+
const artwork = await resolveArtwork(entry.image, MAX_PUBLISH_ARTWORK_BYTES);
|
|
956
|
+
strapi2.log.info(
|
|
957
|
+
`radiocult: requesting ${target} publish of track ${trackId} (entry ${documentId}) — title "${title}", tags [${tags.join(", ")}], artwork ${artwork ? artwork.name : "none"}${force ? ", forced" : ""}`
|
|
958
|
+
);
|
|
959
|
+
const json = (value) => JSON.stringify(value);
|
|
960
|
+
const formData = new FormData();
|
|
961
|
+
if (target === "mixcloud") {
|
|
962
|
+
formData.append("name", json(title));
|
|
963
|
+
formData.append("tags", json(tags.slice(0, 5)));
|
|
964
|
+
formData.append("unlisted", json(false));
|
|
965
|
+
formData.append("description", json(description));
|
|
966
|
+
if (artwork) {
|
|
967
|
+
formData.append(
|
|
968
|
+
"artwork",
|
|
969
|
+
new Blob([new Uint8Array(artwork.buffer)], { type: artwork.mime }),
|
|
970
|
+
artwork.name
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
formData.append("title", json(title));
|
|
975
|
+
formData.append("tags", json(tags));
|
|
976
|
+
formData.append("description", json(description));
|
|
977
|
+
formData.append("sharing", json("public"));
|
|
978
|
+
formData.append("downloadable", "false");
|
|
979
|
+
formData.append("commentable", "true");
|
|
980
|
+
formData.append("embeddableBy", json("all"));
|
|
981
|
+
if (artwork) {
|
|
982
|
+
formData.append(
|
|
983
|
+
"artworkData",
|
|
984
|
+
new Blob([new Uint8Array(artwork.buffer)], { type: artwork.mime }),
|
|
985
|
+
artwork.name
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const res = await rcFetch(
|
|
990
|
+
`/media/track/${trackId}/upload/${target}`,
|
|
991
|
+
{ method: "POST", body: formData },
|
|
992
|
+
12e4
|
|
993
|
+
);
|
|
994
|
+
const body = await res.json().catch(() => ({}));
|
|
995
|
+
if (body?.success === false) {
|
|
996
|
+
throw new RcHttpError(502, `Radio Cult rejected the ${target} publish request`);
|
|
997
|
+
}
|
|
998
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
999
|
+
await records2().upsert(map.contentType, documentId, { [REQUESTED_AT[target]]: now });
|
|
1000
|
+
strapi2.log.info(`radiocult: requested ${target} publish of track ${trackId} (${documentId})`);
|
|
1001
|
+
return { requestedAt: now };
|
|
1002
|
+
},
|
|
1003
|
+
/**
|
|
1004
|
+
* Finds entries whose publish was requested but whose link hasn't
|
|
1005
|
+
* arrived, fetches Radio Cult's track list ONCE, and writes any links
|
|
1006
|
+
* that appeared back onto the mapped fields. Called by the cron sweep,
|
|
1007
|
+
* the status endpoint and the "check now" button.
|
|
1008
|
+
*/
|
|
1009
|
+
async syncLinks({
|
|
1010
|
+
documentIds,
|
|
1011
|
+
bypassCache
|
|
1012
|
+
} = {}) {
|
|
1013
|
+
if (!await isUsable()) return { updated: 0, pending: 0 };
|
|
1014
|
+
const map = await mapping2().mapping();
|
|
1015
|
+
if (!map.contentType || !map.title) return { updated: 0, pending: 0 };
|
|
1016
|
+
if (!map.soundcloudLink && !map.mixcloudLink) return { updated: 0, pending: 0 };
|
|
1017
|
+
const allRecords = await records2().findUploaded(map.contentType, documentIds);
|
|
1018
|
+
const pending = [];
|
|
1019
|
+
for (const record of allRecords) {
|
|
1020
|
+
const raw = await strapi2.documents(map.contentType).findOne({
|
|
1021
|
+
documentId: record.entryDocumentId,
|
|
1022
|
+
populate: mapping2().populateFor(map)
|
|
1023
|
+
});
|
|
1024
|
+
if (!raw) continue;
|
|
1025
|
+
const entry = mapping2().normalise(map, raw);
|
|
1026
|
+
if (PLATFORMS.some((p) => awaitingLink(entry, record, p))) pending.push({ record, entry });
|
|
1027
|
+
}
|
|
1028
|
+
if (pending.length === 0) return { updated: 0, pending: 0 };
|
|
1029
|
+
if (!trackListCache || bypassCache || Date.now() - trackListCache.fetchedAt > TRACK_LIST_CACHE_MS) {
|
|
1030
|
+
const res = await rcFetch("/media/track", { method: "GET" }, 6e4);
|
|
1031
|
+
const body = await res.json().catch(() => ({}));
|
|
1032
|
+
const tracks = Array.isArray(body) ? body : body.tracks ?? body.results ?? body.data ?? [];
|
|
1033
|
+
trackListCache = {
|
|
1034
|
+
fetchedAt: Date.now(),
|
|
1035
|
+
byId: new Map(tracks.map((track) => [track.id, track]))
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
let updated = 0;
|
|
1039
|
+
for (const { record, entry } of pending) {
|
|
1040
|
+
const track = trackListCache.byId.get(record.trackId);
|
|
1041
|
+
if (!track) continue;
|
|
1042
|
+
const links = {};
|
|
1043
|
+
for (const platform of PLATFORMS) {
|
|
1044
|
+
const url = track[RC_URL_FIELD[platform]];
|
|
1045
|
+
if (url && awaitingLink(entry, record, platform)) links[platform] = url;
|
|
1046
|
+
}
|
|
1047
|
+
const written = await mapping2().writeLinks(record.entryDocumentId, links);
|
|
1048
|
+
if (written.length > 0) {
|
|
1049
|
+
await records2().upsert(map.contentType, record.entryDocumentId, {
|
|
1050
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1051
|
+
});
|
|
1052
|
+
updated += 1;
|
|
1053
|
+
strapi2.log.info(
|
|
1054
|
+
`radiocult: saved ${written.join(", ")} for entry ${record.entryDocumentId}`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { updated, pending: pending.length };
|
|
1059
|
+
},
|
|
1060
|
+
/**
|
|
1061
|
+
* Per-entry state for the panel and the uploader page. Includes the
|
|
1062
|
+
* entry details the uploader page prefills its editable form with.
|
|
1063
|
+
*/
|
|
1064
|
+
async getStatus(documentId, { refresh } = {}) {
|
|
1065
|
+
const map = await mapping2().requireMapping();
|
|
1066
|
+
let entry = await mapping2().loadEntry(documentId);
|
|
1067
|
+
let record = await records2().find(map.contentType, documentId);
|
|
1068
|
+
let rateLimitMessage = null;
|
|
1069
|
+
const hasPending = PLATFORMS.some((p) => awaitingLink(entry, record, p));
|
|
1070
|
+
if (record?.trackId && (hasPending || refresh)) {
|
|
1071
|
+
try {
|
|
1072
|
+
await this.syncLinks({ documentIds: [documentId], bypassCache: refresh });
|
|
1073
|
+
entry = await mapping2().loadEntry(documentId);
|
|
1074
|
+
record = await records2().find(map.contentType, documentId);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (error instanceof RcHttpError && error.status === 429) {
|
|
1077
|
+
rateLimitMessage = error.message;
|
|
1078
|
+
} else {
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const pluginSettings = await settings2().get();
|
|
1084
|
+
return {
|
|
1085
|
+
enabled: await isUsable(),
|
|
1086
|
+
trackId: record?.trackId ?? null,
|
|
1087
|
+
uploadedAt: record?.uploadedAt ?? null,
|
|
1088
|
+
platforms: {
|
|
1089
|
+
mixcloud: platformState(entry, record, "mixcloud"),
|
|
1090
|
+
soundcloud: platformState(entry, record, "soundcloud")
|
|
1091
|
+
},
|
|
1092
|
+
rateLimitMessage,
|
|
1093
|
+
// Prefill values for the uploader page's editable form.
|
|
1094
|
+
details: {
|
|
1095
|
+
title: entry.title,
|
|
1096
|
+
// The show this entry belongs to — the uploader page uses it as the
|
|
1097
|
+
// browser tab title so several open tabs stay tellable apart.
|
|
1098
|
+
showName: entry.showName,
|
|
1099
|
+
artist: pluginSettings.defaults.artist,
|
|
1100
|
+
album: albumFor(entry.broadcastDate),
|
|
1101
|
+
description: pluginSettings.defaults.description,
|
|
1102
|
+
genres: entry.tags,
|
|
1103
|
+
image: describeImage(entry.image)
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
},
|
|
1107
|
+
/**
|
|
1108
|
+
* Pushes edited title/artist/album to an already-uploaded Radio Cult
|
|
1109
|
+
* track (the uploader page's "Save details to Radio Cult" button).
|
|
1110
|
+
*/
|
|
1111
|
+
async updateTrackMetadata({
|
|
1112
|
+
documentId,
|
|
1113
|
+
meta
|
|
1114
|
+
}) {
|
|
1115
|
+
const map = await mapping2().requireMapping();
|
|
1116
|
+
const record = await records2().find(map.contentType, documentId);
|
|
1117
|
+
if (!record?.trackId) {
|
|
1118
|
+
throw new RcHttpError(409, "Upload the entry to Radio Cult first");
|
|
1119
|
+
}
|
|
1120
|
+
const data = {};
|
|
1121
|
+
if (cleanString(meta.title)) data.title = cleanString(meta.title);
|
|
1122
|
+
if (cleanString(meta.artist)) data.artist = cleanString(meta.artist);
|
|
1123
|
+
if (cleanString(meta.album)) data.album = cleanString(meta.album);
|
|
1124
|
+
if (Object.keys(data).length === 0) {
|
|
1125
|
+
throw new RcHttpError(400, "Nothing to update — title, artist and album are all empty");
|
|
1126
|
+
}
|
|
1127
|
+
await rcFetch(
|
|
1128
|
+
`/media/track/${record.trackId}`,
|
|
1129
|
+
{
|
|
1130
|
+
method: "PUT",
|
|
1131
|
+
headers: { "content-type": "application/json" },
|
|
1132
|
+
body: JSON.stringify(data)
|
|
1133
|
+
},
|
|
1134
|
+
6e4
|
|
1135
|
+
);
|
|
1136
|
+
strapi2.log.info(
|
|
1137
|
+
`radiocult: updated track ${record.trackId} details for entry ${documentId} (${Object.keys(data).join(", ")})`
|
|
1138
|
+
);
|
|
1139
|
+
return { updated: Object.keys(data) };
|
|
1140
|
+
},
|
|
1141
|
+
/**
|
|
1142
|
+
* Lifecycle hook body: pushes a changed title to the Radio Cult track.
|
|
1143
|
+
* Only fires when the mapped title field actually changed (beforeUpdate
|
|
1144
|
+
* stashes the previous value) — the track title can be customised on the
|
|
1145
|
+
* uploader page, and an unrelated save must not overwrite it.
|
|
1146
|
+
*/
|
|
1147
|
+
async maybeSyncTitle(event) {
|
|
1148
|
+
if (!await isUsable()) return;
|
|
1149
|
+
const map = await mapping2().mapping();
|
|
1150
|
+
if (!map.contentType || !map.title) return;
|
|
1151
|
+
if (event.model?.uid !== map.contentType) return;
|
|
1152
|
+
const title = event.params?.data?.[map.title];
|
|
1153
|
+
if (typeof title !== "string" || !title.trim()) return;
|
|
1154
|
+
const previousTitle = event.state?.radiocultPreviousTitle;
|
|
1155
|
+
if (typeof previousTitle === "string" && previousTitle === title) return;
|
|
1156
|
+
const documentId = event.result?.documentId;
|
|
1157
|
+
if (!documentId) return;
|
|
1158
|
+
const record = await records2().find(map.contentType, documentId);
|
|
1159
|
+
if (!record?.trackId) return;
|
|
1160
|
+
await rcFetch(
|
|
1161
|
+
`/media/track/${record.trackId}`,
|
|
1162
|
+
{
|
|
1163
|
+
method: "PUT",
|
|
1164
|
+
headers: { "content-type": "application/json" },
|
|
1165
|
+
body: JSON.stringify({ title })
|
|
1166
|
+
},
|
|
1167
|
+
6e4
|
|
1168
|
+
);
|
|
1169
|
+
strapi2.log.info(
|
|
1170
|
+
`radiocult: synced title of track ${record.trackId} for entry ${documentId}`
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
};
|
|
1175
|
+
const services = {
|
|
1176
|
+
settings,
|
|
1177
|
+
mapping,
|
|
1178
|
+
records,
|
|
1179
|
+
radiocult
|
|
1180
|
+
};
|
|
1181
|
+
const index = {
|
|
1182
|
+
register,
|
|
1183
|
+
bootstrap,
|
|
1184
|
+
destroy,
|
|
1185
|
+
config,
|
|
1186
|
+
controllers,
|
|
1187
|
+
routes,
|
|
1188
|
+
services,
|
|
1189
|
+
contentTypes,
|
|
1190
|
+
policies,
|
|
1191
|
+
middlewares
|
|
1192
|
+
};
|
|
1193
|
+
exports.default = index;
|