plexsonic 0.1.12 → 0.1.20

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.
@@ -21,23 +21,61 @@
21
21
  import { APP_VERSION } from './version.js';
22
22
 
23
23
  const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
24
+ const ROOT_NAME = 'subsonic-response';
24
25
  const XMLNS = 'http://subsonic.org/restapi';
25
26
  const API_VERSION = '1.16.1';
26
27
  const SERVER_TYPE = 'Plexsonic';
27
28
  const SERVER_VERSION = APP_VERSION;
28
29
  const OPEN_SUBSONIC = true;
29
30
 
31
+ const ROOT_ATTR_KEYS = new Set([
32
+ 'status',
33
+ 'version',
34
+ 'type',
35
+ 'serverVersion',
36
+ 'openSubsonic',
37
+ 'xmlns',
38
+ ]);
30
39
 
40
+ const XML_ATTR_LIST_KEYS = new Set(['genres', 'moods', 'styles', 'recordLabels']);
31
41
 
32
- export function xmlEscape(value) {
33
- const sanitized = sanitizeXmlText(String(value));
34
- return sanitized
35
- .replaceAll('&', '&amp;')
36
- .replaceAll('<', '&lt;')
37
- .replaceAll('>', '&gt;')
38
- .replaceAll('"', '&quot;')
39
- .replaceAll("'", '&apos;');
40
- }
42
+ // Dedicated XML schema rules: these define repeated child elements by parent element.
43
+ const ARRAY_CHILDREN_BY_PARENT = {
44
+ openSubsonicExtensions: new Set(['openSubsonicExtension']),
45
+ albumList: new Set(['album']),
46
+ albumList2: new Set(['album']),
47
+ artists: new Set(['index']),
48
+ indexes: new Set(['index']),
49
+ index: new Set(['artist']),
50
+ artist: new Set(['album']),
51
+ album: new Set(['song']),
52
+ albumArtists: new Set(['artist']),
53
+ songsByGenre: new Set(['song']),
54
+ randomSongs: new Set(['song']),
55
+ topSongs: new Set(['song']),
56
+ similarSongs: new Set(['song']),
57
+ similarSongs2: new Set(['song']),
58
+ searchResult: new Set(['artist', 'album', 'match']),
59
+ searchResult2: new Set(['artist', 'album', 'song']),
60
+ searchResult3: new Set(['artist', 'album', 'song']),
61
+ musicFolders: new Set(['musicFolder']),
62
+ genres: new Set(['genre']),
63
+ playlists: new Set(['playlist']),
64
+ directory: new Set(['child']),
65
+ playlist: new Set(['entry']),
66
+ playQueue: new Set(['entry']),
67
+ nowPlaying: new Set(['entry']),
68
+ lyricsList: new Set(['structuredLyrics']),
69
+ structuredLyrics: new Set(['line']),
70
+ starred: new Set(['artist', 'album', 'song']),
71
+ starred2: new Set(['artist', 'album', 'song']),
72
+ };
73
+
74
+ // Dedicated scalar-child rules for XML where values should be child elements, not attributes.
75
+ const SCALAR_CHILDREN_BY_PARENT = {
76
+ artistInfo: new Set(['biography', 'musicBrainzId']),
77
+ artistInfo2: new Set(['biography', 'musicBrainzId']),
78
+ };
41
79
 
42
80
  function sanitizeXmlText(value) {
43
81
  let output = '';
@@ -57,328 +95,263 @@ function sanitizeXmlText(value) {
57
95
  return output;
58
96
  }
59
97
 
60
- function normalizeChildren(input) {
61
- if (input == null || input === '') {
62
- return [];
63
- }
64
- if (Array.isArray(input)) {
65
- return input.flatMap((item) => normalizeChildren(item));
66
- }
67
- if (typeof input === 'object' && input.kind === 'node') {
68
- return [input];
98
+ export function xmlEscape(value) {
99
+ const sanitized = sanitizeXmlText(String(value));
100
+ return sanitized
101
+ .replaceAll('&', '&amp;')
102
+ .replaceAll('<', '&lt;')
103
+ .replaceAll('>', '&gt;')
104
+ .replaceAll('"', '&quot;')
105
+ .replaceAll("'", '&apos;');
106
+ }
107
+
108
+ function splitList(rawValue) {
109
+ if (Array.isArray(rawValue)) {
110
+ return rawValue.map((entry) => String(entry || '').trim()).filter(Boolean);
69
111
  }
70
- if (typeof input === 'string') {
71
- return [input];
112
+
113
+ const raw = String(rawValue || '').trim();
114
+ if (!raw) {
115
+ return [];
72
116
  }
73
- return [String(input)];
74
- }
75
117
 
76
- function xmlAttrs(attrs = {}) {
77
- return Object.entries(attrs)
78
- .filter(([, value]) => value !== undefined && value !== null)
79
- .map(([key, value]) => ` ${key}="${xmlEscape(value)}"`)
80
- .join('');
118
+ const parts = raw.includes(';') ? raw.split(';') : raw.split(',');
119
+ return parts.map((part) => part.trim()).filter(Boolean);
81
120
  }
82
121
 
83
- function renderXmlPart(part) {
84
- if (typeof part === 'string') {
85
- return xmlEscape(part);
86
- }
87
- if (!part || part.kind !== 'node') {
88
- return '';
122
+ function toXmlAttrValue(key, value) {
123
+ if (value === undefined || value === null) {
124
+ return null;
89
125
  }
90
126
 
91
- const attrs = xmlAttrs(part.attrs);
92
- if (part.selfClosing && part.children.length === 0) {
93
- return `<${part.name}${attrs}/>`;
127
+ if (key === 'genres' || key === 'recordLabels') {
128
+ const names = Array.isArray(value)
129
+ ? value
130
+ .map((entry) => {
131
+ if (entry && typeof entry === 'object') {
132
+ return String(entry.name || '').trim();
133
+ }
134
+ return String(entry || '').trim();
135
+ })
136
+ .filter(Boolean)
137
+ : splitList(value);
138
+ return names.join('; ');
94
139
  }
95
140
 
96
- const inner = part.children.map(renderXmlPart).join('');
97
- return `<${part.name}${attrs}>${inner}</${part.name}>`;
98
- }
99
-
141
+ if (key === 'moods' || key === 'styles') {
142
+ return splitList(value).join('; ');
143
+ }
100
144
 
145
+ if (Array.isArray(value)) {
146
+ return value.map((entry) => String(entry)).join(',');
147
+ }
101
148
 
102
- const NUMERIC_ATTRS = new Set([
103
- 'code',
104
- 'count',
105
- 'offset',
106
- 'duration',
107
- 'songCount',
108
- 'albumCount',
109
- 'track',
110
- 'discNumber',
111
- 'bitRate',
112
- 'size',
113
- 'year',
114
- 'userRating',
115
- 'playCount',
116
- 'leafCount',
117
- 'leafCountAdded',
118
- 'leafCountRequested',
119
- 'position',
120
- 'lastModified',
121
- 'start',
122
- 'sampleRate',
123
- 'bitDepth',
124
- ]);
149
+ if (typeof value === 'boolean') {
150
+ return value ? 'true' : 'false';
151
+ }
125
152
 
126
- const BOOLEAN_ATTRS = new Set([
127
- 'openSubsonic',
128
- 'valid',
129
- 'scanning',
130
- 'isDir',
131
- 'public',
132
- 'scrobblingEnabled',
133
- 'adminRole',
134
- 'settingsRole',
135
- 'downloadRole',
136
- 'uploadRole',
137
- 'playlistRole',
138
- 'coverArtRole',
139
- 'commentRole',
140
- 'podcastRole',
141
- 'streamRole',
142
- 'jukeboxRole',
143
- 'shareRole',
144
- 'videoConversionRole',
145
- 'smart',
146
- 'synced',
147
- 'readonly',
148
- 'compilation',
149
- 'soundtrack',
150
- ]);
153
+ return String(value);
154
+ }
151
155
 
152
- const ARRAY_CHILDREN_BY_PARENT = {
153
- openSubsonicExtensions: new Set(['openSubsonicExtension']),
154
- albumList: new Set(['album']),
155
- albumList2: new Set(['album']),
156
- artists: new Set(['index']),
157
- indexes: new Set(['index']),
158
- index: new Set(['artist']),
159
- artist: new Set(['album']),
160
- album: new Set(['song']),
161
- albumArtists: new Set(['artist']),
162
- songsByGenre: new Set(['song']),
163
- randomSongs: new Set(['song']),
164
- topSongs: new Set(['song']),
165
- searchResult: new Set(['artist', 'album', 'match']),
166
- searchResult2: new Set(['artist', 'album', 'song']),
167
- searchResult3: new Set(['artist', 'album', 'song']),
168
- musicFolders: new Set(['musicFolder']),
169
- genres: new Set(['genre']),
170
- playlists: new Set(['playlist']),
171
- directory: new Set(['child']),
172
- playlist: new Set(['entry']),
173
- playQueue: new Set(['entry']),
174
- nowPlaying: new Set(['entry']),
175
- lyricsList: new Set(['structuredLyrics']),
176
- structuredLyrics: new Set(['line']),
177
- starred: new Set(['artist', 'album', 'song']),
178
- starred2: new Set(['artist', 'album', 'song']),
179
- };
156
+ function xmlAttrs(attrs = {}) {
157
+ return Object.entries(attrs)
158
+ .map(([key, value]) => {
159
+ const attrValue = toXmlAttrValue(key, value);
160
+ if (attrValue == null) {
161
+ return '';
162
+ }
163
+ return ` ${key}="${xmlEscape(attrValue)}"`;
164
+ })
165
+ .join('');
166
+ }
180
167
 
181
168
  function shouldUseArray(parentName, childName) {
182
169
  return ARRAY_CHILDREN_BY_PARENT[parentName]?.has(childName) || false;
183
170
  }
184
171
 
185
- function coerceAttrValue(key, value) {
186
- const splitList = (rawValue) => {
187
- if (Array.isArray(rawValue)) {
188
- return rawValue.map((entry) => String(entry || '').trim()).filter(Boolean);
189
- }
190
- const raw = String(rawValue || '').trim();
191
- if (!raw) {
192
- return [];
193
- }
194
- const parts = raw.includes(';') ? raw.split(';') : raw.split(',');
195
- return parts.map((part) => part.trim()).filter(Boolean);
196
- };
172
+ function isScalarChild(parentName, childName) {
173
+ return SCALAR_CHILDREN_BY_PARENT[parentName]?.has(childName) || false;
174
+ }
197
175
 
198
- if (key === 'genres') {
199
- const genreNames = splitList(value);
200
- return genreNames.map((name) => ({ name }));
176
+ function defaultArrayChildName(parentName) {
177
+ const children = ARRAY_CHILDREN_BY_PARENT[parentName];
178
+ if (!children || children.size !== 1) {
179
+ return null;
201
180
  }
181
+ return [...children][0];
182
+ }
202
183
 
203
- if (key === 'moods') {
204
- return splitList(value);
184
+ function isFlattenArtistList(name, value) {
185
+ if ((name !== 'artists' && name !== 'albumArtists') || !Array.isArray(value)) {
186
+ return false;
187
+ }
188
+ if (value.length === 0) {
189
+ return true;
205
190
  }
191
+ return value.every((entry) => entry && typeof entry === 'object' && !Array.isArray(entry));
192
+ }
206
193
 
207
- if (key === 'styles') {
208
- return splitList(value);
194
+ function shouldRenderAsChild(parentName, key, value) {
195
+ if (parentName === ROOT_NAME) {
196
+ return !ROOT_ATTR_KEYS.has(key);
209
197
  }
210
198
 
211
- if (key === 'recordLabels') {
212
- const labels = splitList(value);
213
- return labels.map((name) => ({ name }));
199
+ if (key === 'value') {
200
+ return false;
214
201
  }
215
202
 
216
- if (BOOLEAN_ATTRS.has(key)) {
217
- if (value === 'true') {
218
- return true;
219
- }
220
- if (value === 'false') {
221
- return false;
222
- }
203
+ if (isScalarChild(parentName, key)) {
204
+ return true;
223
205
  }
224
206
 
225
- if (NUMERIC_ATTRS.has(key) && /^-?\d+$/.test(String(value))) {
226
- return Number(value);
207
+ if (shouldUseArray(parentName, key)) {
208
+ return true;
227
209
  }
228
210
 
229
- return value;
211
+ if (XML_ATTR_LIST_KEYS.has(key)) {
212
+ return false;
213
+ }
214
+
215
+ if (Array.isArray(value)) {
216
+ return value.some((entry) => entry && typeof entry === 'object');
217
+ }
218
+
219
+ return Boolean(value && typeof value === 'object');
230
220
  }
231
221
 
232
- function nodeToJson(node) {
233
- const out = {};
234
- for (const [key, value] of Object.entries(node.attrs || {})) {
235
- out[key] = coerceAttrValue(key, value);
222
+ function renderXmlElement(name, value) {
223
+ if (value === undefined || value === null) {
224
+ return `<${name}/>`;
236
225
  }
237
226
 
238
- for (const child of node.children || []) {
239
- if (typeof child === 'string') {
240
- if (child.trim()) {
241
- out.value = (out.value || '') + child;
242
- }
227
+ if (typeof value !== 'object') {
228
+ return `<${name}>${xmlEscape(value)}</${name}>`;
229
+ }
230
+
231
+ if (Array.isArray(value)) {
232
+ const childName =
233
+ (name === 'artists' || name === 'albumArtists')
234
+ ? 'artist'
235
+ : defaultArrayChildName(name);
236
+ const attrs = isFlattenArtistList(name, value) ? { flatten: 'true' } : {};
237
+
238
+ if (!childName) {
239
+ const text = value.map((entry) => String(entry)).join(', ');
240
+ return `<${name}${xmlAttrs(attrs)}>${xmlEscape(text)}</${name}>`;
241
+ }
242
+
243
+ const inner = value.map((entry) => renderXmlElement(childName, entry)).join('');
244
+ if (!inner) {
245
+ return `<${name}${xmlAttrs(attrs)}/>`;
246
+ }
247
+
248
+ return `<${name}${xmlAttrs(attrs)}>${inner}</${name}>`;
249
+ }
250
+
251
+ const attrs = {};
252
+ let textValue = '';
253
+ const childParts = [];
254
+
255
+ for (const [key, childValue] of Object.entries(value)) {
256
+ if (key === 'value') {
257
+ textValue += String(childValue || '');
243
258
  continue;
244
259
  }
245
260
 
246
- const value = nodeToJson(child);
247
- if (shouldUseArray(node.name, child.name)) {
248
- if (!Array.isArray(out[child.name])) {
249
- out[child.name] = [];
250
- }
251
- out[child.name].push(value);
261
+ if (!shouldRenderAsChild(name, key, childValue)) {
262
+ attrs[key] = childValue;
252
263
  continue;
253
264
  }
254
265
 
255
- if (Object.prototype.hasOwnProperty.call(out, child.name)) {
256
- if (!Array.isArray(out[child.name])) {
257
- out[child.name] = [out[child.name]];
266
+ if (Array.isArray(childValue)) {
267
+ if (shouldUseArray(name, key)) {
268
+ for (const entry of childValue) {
269
+ childParts.push(renderXmlElement(key, entry));
270
+ }
271
+ } else {
272
+ childParts.push(renderXmlElement(key, childValue));
258
273
  }
259
- out[child.name].push(value);
260
274
  continue;
261
275
  }
262
276
 
263
- out[child.name] = value;
277
+ childParts.push(renderXmlElement(key, childValue));
264
278
  }
265
279
 
266
- const arrayChildren = ARRAY_CHILDREN_BY_PARENT[node.name];
267
- if (arrayChildren) {
268
- for (const childName of arrayChildren) {
269
- if (!Object.prototype.hasOwnProperty.call(out, childName)) {
270
- out[childName] = [];
271
- }
272
- }
280
+ const inner = `${xmlEscape(textValue)}${childParts.join('')}`;
281
+ if (!inner) {
282
+ return `<${name}${xmlAttrs(attrs)}/>`;
273
283
  }
274
284
 
275
- const keys = Object.keys(out);
276
- if (keys.length === 1 && keys[0] === 'value') {
277
- return out.value;
278
- }
285
+ return `<${name}${xmlAttrs(attrs)}>${inner}</${name}>`;
286
+ }
279
287
 
280
- if (node.name === 'artists' || node.name === 'albumArtists') {
281
- if (node.attrs?.flatten === 'true') {
282
- const artistValue = out.artist;
283
- if (Array.isArray(artistValue)) {
284
- return artistValue;
285
- }
286
- if (artistValue != null) {
287
- return [artistValue];
288
- }
289
- return [];
290
- }
288
+ function normalizeInner(inner) {
289
+ if (!inner || typeof inner !== 'object' || Array.isArray(inner)) {
290
+ return {};
291
291
  }
292
-
293
- if (node.name === 'openSubsonicExtensions') {
294
- const extensions = out.openSubsonicExtension;
295
- if (Array.isArray(extensions)) {
296
- return extensions;
292
+ const out = {};
293
+ for (const [key, value] of Object.entries(inner)) {
294
+ if (Array.isArray(value)) {
295
+ const childName = defaultArrayChildName(key);
296
+ if (childName && key !== 'artists' && key !== 'albumArtists') {
297
+ out[key] = { [childName]: value };
298
+ } else {
299
+ out[key] = value;
300
+ }
301
+ continue;
297
302
  }
298
- return extensions == null ? [] : [extensions];
303
+ out[key] = value;
299
304
  }
300
-
301
305
  return out;
302
306
  }
303
307
 
304
- function subsonicRoot(status, children = []) {
308
+ function buildRoot(status, inner = {}) {
305
309
  return {
306
- kind: 'node',
307
- name: 'subsonic-response',
308
- selfClosing: false,
309
- attrs: {
310
- status,
311
- version: API_VERSION,
312
- type: SERVER_TYPE,
313
- serverVersion: SERVER_VERSION,
314
- openSubsonic: OPEN_SUBSONIC,
315
- xmlns: XMLNS,
316
- },
317
- children,
310
+ status,
311
+ version: API_VERSION,
312
+ type: SERVER_TYPE,
313
+ serverVersion: SERVER_VERSION,
314
+ openSubsonic: OPEN_SUBSONIC,
315
+ ...normalizeInner(inner),
318
316
  };
319
317
  }
320
318
 
321
- export function emptyNode(name, attrs = {}) {
319
+ export function okResponseJson(inner = {}) {
322
320
  return {
323
- kind: 'node',
324
- name,
325
- attrs,
326
- children: [],
327
- selfClosing: true,
321
+ [ROOT_NAME]: buildRoot('ok', inner),
328
322
  };
329
323
  }
330
324
 
331
- export function node(name, attrs = {}, inner = null) {
325
+ export function failedResponseJson(code, message) {
332
326
  return {
333
- kind: 'node',
334
- name,
335
- attrs,
336
- children: normalizeChildren(inner),
337
- selfClosing: false,
327
+ [ROOT_NAME]: buildRoot('failed', {
328
+ error: {
329
+ code,
330
+ message: String(message),
331
+ },
332
+ }),
338
333
  };
339
334
  }
340
335
 
341
- export function okResponse(inner = '') {
342
- const root = subsonicRoot('ok', normalizeChildren(inner));
343
- return `${XML_HEADER}${renderXmlPart(root)}`;
344
- }
345
-
346
- export function failedResponse(code, message) {
347
- const errorNode = {
348
- kind: 'node',
349
- name: 'error',
350
- attrs: {
351
- code,
352
- message: String(message),
353
- },
354
- children: [],
355
- selfClosing: true,
356
- };
357
- const root = subsonicRoot('failed', [errorNode]);
358
- return `${XML_HEADER}${renderXmlPart(root)}`;
336
+ export function subsonicJsonToXml(payload) {
337
+ const rootValue = payload?.[ROOT_NAME];
338
+ const safeRoot = rootValue && typeof rootValue === 'object' && !Array.isArray(rootValue)
339
+ ? rootValue
340
+ : buildRoot('failed', {
341
+ error: {
342
+ code: 0,
343
+ message: 'Invalid Subsonic payload',
344
+ },
345
+ });
346
+
347
+ const xmlRoot = safeRoot.xmlns ? safeRoot : { ...safeRoot, xmlns: XMLNS };
348
+ return `${XML_HEADER}${renderXmlElement(ROOT_NAME, xmlRoot)}`;
359
349
  }
360
350
 
361
- export function okResponseJson(inner = '') {
362
- const root = subsonicRoot('ok', normalizeChildren(inner));
363
- const json = nodeToJson(root);
364
- delete json.xmlns;
365
- return { 'subsonic-response': json };
351
+ export function okResponse(inner = {}) {
352
+ return subsonicJsonToXml(okResponseJson(inner));
366
353
  }
367
354
 
368
- export function failedResponseJson(code, message) {
369
- const errorNode = {
370
- kind: 'node',
371
- name: 'error',
372
- attrs: {
373
- code,
374
- message: String(message),
375
- },
376
- children: [],
377
- selfClosing: true,
378
- };
379
- const root = subsonicRoot('failed', [errorNode]);
380
- const json = nodeToJson(root);
381
- delete json.xmlns;
382
- return { 'subsonic-response': json };
355
+ export function failedResponse(code, message) {
356
+ return subsonicJsonToXml(failedResponseJson(code, message));
383
357
  }
384
-