@ngxsp/core 0.0.1-next.0 → 0.1.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 +28 -0
- package/index.cjs +4290 -57
- package/index.cjs.map +1 -1
- package/index.d.cts +1646 -8
- package/index.d.ts +1646 -8
- package/index.js +4131 -58
- package/index.js.map +1 -1
- package/package.json +17 -10
package/index.js
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
import { BehaviorSubject, Subject } from 'rxjs';
|
|
2
2
|
|
|
3
|
-
// src/
|
|
3
|
+
// src/config/default-config.ts
|
|
4
|
+
var DEFAULT_PROGRESS_CONFIG = {
|
|
5
|
+
mode: "local",
|
|
6
|
+
intervalSec: 5
|
|
7
|
+
};
|
|
8
|
+
var DEFAULT_UI_CONFIG = {
|
|
9
|
+
ratios: ["16:9"],
|
|
10
|
+
defaultRatio: "16:9",
|
|
11
|
+
keyboardShortcuts: true
|
|
12
|
+
};
|
|
13
|
+
var DEFAULT_RESILIENCE_CONFIG = {
|
|
14
|
+
retries: 2,
|
|
15
|
+
backoffMs: { min: 300, max: 1500 }
|
|
16
|
+
};
|
|
17
|
+
var DEFAULT_SUBTITLES_CONFIG = {
|
|
18
|
+
enabled: false,
|
|
19
|
+
providers: ["local"]
|
|
20
|
+
};
|
|
21
|
+
var DEFAULT_METADATA_CONFIG = {
|
|
22
|
+
enabled: false,
|
|
23
|
+
providers: ["local"],
|
|
24
|
+
localSidecar: false
|
|
25
|
+
};
|
|
26
|
+
var DEFAULT_ADS_CONFIG = {
|
|
27
|
+
enabled: false,
|
|
28
|
+
mode: "csai"
|
|
29
|
+
};
|
|
30
|
+
function createDefaultConfig(overrides) {
|
|
31
|
+
const {
|
|
32
|
+
source,
|
|
33
|
+
progress,
|
|
34
|
+
ui,
|
|
35
|
+
resilience,
|
|
36
|
+
subtitles,
|
|
37
|
+
metadata,
|
|
38
|
+
ads,
|
|
39
|
+
...rest
|
|
40
|
+
} = overrides;
|
|
41
|
+
return {
|
|
42
|
+
// Pass through every other (mostly optional) top-level field as provided:
|
|
43
|
+
// profile, drm, series, playlist, playlistStartIndex, autoNext, offline,
|
|
44
|
+
// remoteConfig, ...
|
|
45
|
+
...rest,
|
|
46
|
+
source,
|
|
47
|
+
progress: { ...DEFAULT_PROGRESS_CONFIG, ...progress },
|
|
48
|
+
ui: { ...DEFAULT_UI_CONFIG, ...ui },
|
|
49
|
+
resilience: {
|
|
50
|
+
...DEFAULT_RESILIENCE_CONFIG,
|
|
51
|
+
...resilience,
|
|
52
|
+
backoffMs: {
|
|
53
|
+
...DEFAULT_RESILIENCE_CONFIG.backoffMs,
|
|
54
|
+
...resilience?.backoffMs
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
subtitles: { ...DEFAULT_SUBTITLES_CONFIG, ...subtitles },
|
|
58
|
+
metadata: { ...DEFAULT_METADATA_CONFIG, ...metadata },
|
|
59
|
+
ads: { ...DEFAULT_ADS_CONFIG, ...ads }
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/domain/remote-playback.ts
|
|
64
|
+
var RemotePlaybackError = class extends Error {
|
|
65
|
+
constructor(code, message, format) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.code = code;
|
|
68
|
+
this.format = format;
|
|
69
|
+
this.name = "RemotePlaybackError";
|
|
70
|
+
}
|
|
71
|
+
code;
|
|
72
|
+
format;
|
|
73
|
+
};
|
|
74
|
+
function remotePlaybackErrorCode(code) {
|
|
75
|
+
return `REMOTE_${code.toUpperCase().replace(/-/g, "_")}`;
|
|
76
|
+
}
|
|
77
|
+
function remotePlaybackFailure(error) {
|
|
78
|
+
if (!error) return null;
|
|
79
|
+
const details = error.details;
|
|
80
|
+
if (details?.name === "RemotePlaybackError" && details.code) return { code: details.code, ...details.format ? { format: details.format } : {} };
|
|
81
|
+
if (error.code === "REMOTE_PLAYBACK") return { code: "load-failed" };
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function formatRemoteClock(seconds) {
|
|
85
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
86
|
+
const hours = Math.floor(total / 3600);
|
|
87
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
88
|
+
const rest = total % 60;
|
|
89
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
90
|
+
}
|
|
91
|
+
function parseRemoteClock(value) {
|
|
92
|
+
const match = /^(\d+):(\d{1,2}):(\d{1,2})(?:\.(\d+))?$/.exec(value?.trim() ?? "");
|
|
93
|
+
if (!match) return void 0;
|
|
94
|
+
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + (match[4] ? Number(`0.${match[4]}`) : 0);
|
|
95
|
+
}
|
|
4
96
|
|
|
5
97
|
// src/engine/engine-factory.ts
|
|
6
98
|
function selectEngine(config, factories, caps) {
|
|
@@ -50,14 +142,21 @@ function mimeFromUrl(url) {
|
|
|
50
142
|
return "";
|
|
51
143
|
}
|
|
52
144
|
}
|
|
145
|
+
var NO_SOURCE = 3;
|
|
146
|
+
function canPlay(v, type) {
|
|
147
|
+
return typeof v.canPlayType !== "function" || v.canPlayType(type) !== "";
|
|
148
|
+
}
|
|
53
149
|
var NativeEngine = class {
|
|
54
150
|
id = "native";
|
|
55
151
|
video;
|
|
56
152
|
listeners = [];
|
|
153
|
+
emit(e) {
|
|
154
|
+
this.listeners.forEach((l) => l(e));
|
|
155
|
+
}
|
|
57
156
|
async attach(videoEl) {
|
|
58
157
|
this.video = videoEl;
|
|
59
158
|
const v = videoEl;
|
|
60
|
-
const emit = (e) => this.
|
|
159
|
+
const emit = (e) => this.emit(e);
|
|
61
160
|
v.addEventListener("timeupdate", () => emit({ type: "timeupdate", currentTime: v.currentTime, duration: v.duration || 0 }));
|
|
62
161
|
v.addEventListener("playing", () => emit({ type: "state", state: "playing" }));
|
|
63
162
|
v.addEventListener("pause", () => emit({ type: "state", state: "paused" }));
|
|
@@ -79,12 +178,25 @@ var NativeEngine = class {
|
|
|
79
178
|
v.src = URL.createObjectURL(source.file);
|
|
80
179
|
} else {
|
|
81
180
|
if (!source.url) throw new Error("source.url is required");
|
|
82
|
-
const
|
|
181
|
+
const declared = mimeFromUrl(source.url);
|
|
182
|
+
const type = declared && canPlay(v, declared) ? declared : "";
|
|
83
183
|
while (v.firstChild) v.removeChild(v.firstChild);
|
|
84
184
|
if (type) {
|
|
85
185
|
const s = document.createElement("source");
|
|
86
186
|
s.src = source.url;
|
|
87
187
|
s.type = type;
|
|
188
|
+
s.addEventListener("error", () => {
|
|
189
|
+
if (v.networkState !== NO_SOURCE) return;
|
|
190
|
+
this.emit({
|
|
191
|
+
type: "error",
|
|
192
|
+
error: {
|
|
193
|
+
code: "HTML5_4",
|
|
194
|
+
message: `No source could be decoded (${type})`,
|
|
195
|
+
fatal: true,
|
|
196
|
+
details: { url: source.url, type }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
88
200
|
v.appendChild(s);
|
|
89
201
|
v.removeAttribute("src");
|
|
90
202
|
} else {
|
|
@@ -94,6 +206,8 @@ var NativeEngine = class {
|
|
|
94
206
|
await v.load();
|
|
95
207
|
if (opts?.startTimeSec != null && isFinite(opts.startTimeSec)) {
|
|
96
208
|
v.currentTime = Math.max(0, opts.startTimeSec);
|
|
209
|
+
} else if (!source.live) {
|
|
210
|
+
v.currentTime = 0;
|
|
97
211
|
}
|
|
98
212
|
}
|
|
99
213
|
async destroy() {
|
|
@@ -116,7 +230,10 @@ var NativeEngine = class {
|
|
|
116
230
|
if (this.video) this.video.playbackRate = rate;
|
|
117
231
|
}
|
|
118
232
|
async setVolume(volume) {
|
|
119
|
-
if (this.video)
|
|
233
|
+
if (!this.video) return;
|
|
234
|
+
const level = Math.min(1, Math.max(0, volume));
|
|
235
|
+
this.video.volume = level;
|
|
236
|
+
this.video.muted = level <= 0;
|
|
120
237
|
}
|
|
121
238
|
async setTextTrack(_trackId) {
|
|
122
239
|
}
|
|
@@ -241,7 +358,7 @@ async function detectPlaybackCapabilities(videoEl) {
|
|
|
241
358
|
caps.eme = !!(w.navigator && w.navigator.requestMediaKeySystemAccess);
|
|
242
359
|
caps.mediaCapabilities = !!(w.navigator && w.navigator.mediaCapabilities);
|
|
243
360
|
const v = videoEl ?? (typeof document !== "undefined" ? document.createElement("video") : void 0);
|
|
244
|
-
const
|
|
361
|
+
const canPlay2 = (mime) => {
|
|
245
362
|
try {
|
|
246
363
|
if (caps.mse && w.MediaSource?.isTypeSupported) return !!w.MediaSource.isTypeSupported(mime);
|
|
247
364
|
if (v && typeof v.canPlayType === "function") return v.canPlayType(mime) !== "";
|
|
@@ -250,12 +367,12 @@ async function detectPlaybackCapabilities(videoEl) {
|
|
|
250
367
|
return false;
|
|
251
368
|
}
|
|
252
369
|
};
|
|
253
|
-
caps.codecs.mp4_h264 =
|
|
254
|
-
caps.codecs.mp4_hevc =
|
|
255
|
-
caps.codecs.webm_vp9 =
|
|
256
|
-
caps.codecs.webm_av1 =
|
|
370
|
+
caps.codecs.mp4_h264 = canPlay2('video/mp4; codecs="avc1.42E01E"');
|
|
371
|
+
caps.codecs.mp4_hevc = canPlay2('video/mp4; codecs="hvc1.1.6.L93.B0"');
|
|
372
|
+
caps.codecs.webm_vp9 = canPlay2('video/webm; codecs="vp09.00.10.08"');
|
|
373
|
+
caps.codecs.webm_av1 = canPlay2('video/webm; codecs="av01.0.05M.08"');
|
|
257
374
|
caps.dash = caps.mse;
|
|
258
|
-
caps.hls =
|
|
375
|
+
caps.hls = canPlay2("application/vnd.apple.mpegurl") || canPlay2("application/x-mpegURL") || caps.mse;
|
|
259
376
|
caps.progressive = caps.codecs.mp4_h264 || caps.codecs.webm_vp9 || caps.codecs.webm_av1 || true;
|
|
260
377
|
if (caps.eme && w.navigator?.requestMediaKeySystemAccess) {
|
|
261
378
|
const probe = async (ks) => {
|
|
@@ -330,7 +447,7 @@ function createCapabilityReport(videoEl, cfg, mediaDecoding) {
|
|
|
330
447
|
const mediaCapabilities = !!(w.navigator && w.navigator.mediaCapabilities);
|
|
331
448
|
if (!mse) notes.push("MediaSource (MSE) not available");
|
|
332
449
|
if (!eme) notes.push("Encrypted Media Extensions (EME) not available");
|
|
333
|
-
const
|
|
450
|
+
const canPlay2 = {
|
|
334
451
|
'video/mp4; codecs="avc1.42E01E"': canPlayType(videoEl, 'video/mp4; codecs="avc1.42E01E"'),
|
|
335
452
|
'video/mp4; codecs="hvc1.1.6.L93.B0"': canPlayType(videoEl, 'video/mp4; codecs="hvc1.1.6.L93.B0"'),
|
|
336
453
|
'video/webm; codecs="vp09.00.10.08"': canPlayType(videoEl, 'video/webm; codecs="vp09.00.10.08"'),
|
|
@@ -361,7 +478,7 @@ function createCapabilityReport(videoEl, cfg, mediaDecoding) {
|
|
|
361
478
|
} : void 0,
|
|
362
479
|
platform: { isSecureContext: secure },
|
|
363
480
|
features: { mse, eme, mediaCapabilities },
|
|
364
|
-
canPlay,
|
|
481
|
+
canPlay: canPlay2,
|
|
365
482
|
mediaDecoding,
|
|
366
483
|
notes
|
|
367
484
|
};
|
|
@@ -505,10 +622,10 @@ function createDefaultPorts() {
|
|
|
505
622
|
|
|
506
623
|
// src/facade/player-facade.ts
|
|
507
624
|
var PlayerFacade = class {
|
|
508
|
-
constructor(
|
|
509
|
-
this.chooseEngine = chooseEngine;
|
|
625
|
+
constructor(ports = createDefaultPorts()) {
|
|
510
626
|
this.ports = ports;
|
|
511
627
|
}
|
|
628
|
+
ports;
|
|
512
629
|
engineFactories = [createNativeEngineFactory()];
|
|
513
630
|
engineId = "native";
|
|
514
631
|
lastNonFatal = null;
|
|
@@ -538,16 +655,21 @@ var PlayerFacade = class {
|
|
|
538
655
|
playlistIndex$ = this.playlistIndexSub.asObservable();
|
|
539
656
|
playlist = [];
|
|
540
657
|
playlistKey = "";
|
|
541
|
-
|
|
658
|
+
/** Subtitles loaded from files, shown by the host from `captionCues$` rather than by the browser. */
|
|
659
|
+
externalTexts = [];
|
|
660
|
+
externalTextCount = 0;
|
|
661
|
+
/** The text track picked here; undefined while the engine's own choice stands. */
|
|
662
|
+
textSelection = void 0;
|
|
663
|
+
engineTracks = [];
|
|
664
|
+
offCueChange;
|
|
665
|
+
captionCuesSub = new BehaviorSubject([]);
|
|
666
|
+
/** The cues of the loaded subtitles to show now: the host draws them over the video. */
|
|
667
|
+
captionCues$ = this.captionCuesSub.asObservable();
|
|
542
668
|
attach(videoEl, plugins = [], config, ports, engines) {
|
|
543
669
|
if (ports) {
|
|
544
670
|
this.ports = { ...this.ports, ...ports };
|
|
545
671
|
}
|
|
546
|
-
|
|
547
|
-
this.engineFactories = [createNativeEngineFactory(), ...engines];
|
|
548
|
-
} else {
|
|
549
|
-
this.engineFactories = [createNativeEngineFactory()];
|
|
550
|
-
}
|
|
672
|
+
this.setEngines(engines);
|
|
551
673
|
this.video = videoEl;
|
|
552
674
|
this.plugins = plugins ?? [];
|
|
553
675
|
this.ctx = { config, facade: this, ports: this.ports, contentId: config.source.contentId };
|
|
@@ -595,6 +717,7 @@ var PlayerFacade = class {
|
|
|
595
717
|
}
|
|
596
718
|
this.ctx.config = config;
|
|
597
719
|
this.ctx.contentId = config.source.contentId;
|
|
720
|
+
this.timeSub.next({ currentTime: Math.max(0, opts?.startTimeSec ?? 0), duration: 0 });
|
|
598
721
|
this.emitTelemetry("player:load_start", "info");
|
|
599
722
|
try {
|
|
600
723
|
const now = globalThis.performance?.now?.() ?? Date.now();
|
|
@@ -613,6 +736,7 @@ var PlayerFacade = class {
|
|
|
613
736
|
} catch {
|
|
614
737
|
}
|
|
615
738
|
}
|
|
739
|
+
this.clearExternalTextTracks();
|
|
616
740
|
if (this.engine) {
|
|
617
741
|
try {
|
|
618
742
|
await this.engine.destroy();
|
|
@@ -659,6 +783,13 @@ var PlayerFacade = class {
|
|
|
659
783
|
}
|
|
660
784
|
}
|
|
661
785
|
}
|
|
786
|
+
/**
|
|
787
|
+
* Replaces the engines the next load chooses from (a remote device session starting or ending). The native engine is
|
|
788
|
+
* always kept as fallback so progressive and local playback never breaks; call `load` or `reload` to switch now.
|
|
789
|
+
*/
|
|
790
|
+
setEngines(engines) {
|
|
791
|
+
this.engineFactories = engines && engines.length ? [createNativeEngineFactory(), ...engines] : [createNativeEngineFactory()];
|
|
792
|
+
}
|
|
662
793
|
async reload(opts) {
|
|
663
794
|
if (!this.ctx) throw new Error("PlayerFacade context missing");
|
|
664
795
|
const snap = this.snapshot();
|
|
@@ -700,6 +831,7 @@ var PlayerFacade = class {
|
|
|
700
831
|
await this.engine?.destroy();
|
|
701
832
|
} catch {
|
|
702
833
|
}
|
|
834
|
+
this.clearExternalTextTracks();
|
|
703
835
|
this.engine = void 0;
|
|
704
836
|
this.video = void 0;
|
|
705
837
|
this.ctx = void 0;
|
|
@@ -853,9 +985,17 @@ var PlayerFacade = class {
|
|
|
853
985
|
this.emitTelemetry("player:play_ok", "info");
|
|
854
986
|
} catch (err) {
|
|
855
987
|
const name = err?.name;
|
|
988
|
+
if (name === "AbortError") {
|
|
989
|
+
this.emitTelemetry("player:play_aborted", "info", {}, err?.message);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
856
992
|
if (name === "NotAllowedError") {
|
|
857
993
|
this.emitNonFatal(err, "AUTOPLAY_BLOCKED");
|
|
858
994
|
this.emitTelemetry("player:play_fail", "warn", { code: "AUTOPLAY_BLOCKED" }, err?.message);
|
|
995
|
+
} else if (name === "RemotePlaybackError") {
|
|
996
|
+
const code = remotePlaybackErrorCode(err.code);
|
|
997
|
+
this.emitFatal(err, code);
|
|
998
|
+
this.emitTelemetry("player:play_fail", "error", { code }, err?.message);
|
|
859
999
|
} else {
|
|
860
1000
|
this.emitFatal(err, "UNKNOWN");
|
|
861
1001
|
this.emitTelemetry("player:play_fail", "error", { code: "UNKNOWN" }, err?.message);
|
|
@@ -866,8 +1006,15 @@ var PlayerFacade = class {
|
|
|
866
1006
|
async pause() {
|
|
867
1007
|
await this.engine?.pause();
|
|
868
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Seeks the attached engine. Resolves to false when no engine can take the request yet (a load is replacing it), so
|
|
1011
|
+
* callers keep the position and apply it once the media is ready instead of assuming it happened.
|
|
1012
|
+
*/
|
|
869
1013
|
async seekTo(timeSec) {
|
|
870
|
-
|
|
1014
|
+
const engine = this.engine;
|
|
1015
|
+
if (!engine) return false;
|
|
1016
|
+
await engine.seekTo(timeSec);
|
|
1017
|
+
return true;
|
|
871
1018
|
}
|
|
872
1019
|
async setRate(rate) {
|
|
873
1020
|
await this.engine?.setPlaybackRate(rate);
|
|
@@ -875,14 +1022,34 @@ var PlayerFacade = class {
|
|
|
875
1022
|
async setVolume(volume) {
|
|
876
1023
|
await this.engine?.setVolume(volume);
|
|
877
1024
|
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Shows a text track: one of the engine's, one loaded from a file (`addExternalTextTrack`), or none (null).
|
|
1027
|
+
* "external" still names the last file loaded.
|
|
1028
|
+
*/
|
|
878
1029
|
async setTextTrack(trackId) {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1030
|
+
const id = trackId === "external" ? this.externalTexts[this.externalTexts.length - 1]?.id ?? null : trackId;
|
|
1031
|
+
const external = this.externalTexts.find((text6) => text6.id === id);
|
|
1032
|
+
this.showExternalText(external);
|
|
1033
|
+
await this.engine?.setTextTrack(external ? null : id);
|
|
1034
|
+
this.textSelection = id;
|
|
1035
|
+
this.emitTracks();
|
|
1036
|
+
}
|
|
1037
|
+
/** Adds subtitles read from a file (WebVTT) to the media playing, and shows them; resolves to the new track's id. */
|
|
1038
|
+
async addExternalTextTrack(text6) {
|
|
1039
|
+
if (!this.video) throw new Error("PlayerFacade not attached");
|
|
1040
|
+
const id = `external:${++this.externalTextCount}`;
|
|
1041
|
+
const url = URL.createObjectURL(new Blob([text6.vtt], { type: "text/vtt" }));
|
|
1042
|
+
const element = document.createElement("track");
|
|
1043
|
+
element.kind = "subtitles";
|
|
1044
|
+
element.label = text6.label;
|
|
1045
|
+
if (text6.language) element.srclang = text6.language;
|
|
1046
|
+
element.src = url;
|
|
1047
|
+
element.setAttribute("data-external", "true");
|
|
1048
|
+
this.video.appendChild(element);
|
|
1049
|
+
element.track.mode = "disabled";
|
|
1050
|
+
this.externalTexts.push({ id, label: text6.label, ...text6.language ? { language: text6.language } : {}, url, element });
|
|
1051
|
+
await this.setTextTrack(id);
|
|
1052
|
+
return id;
|
|
886
1053
|
}
|
|
887
1054
|
async setAudioTrack(trackId) {
|
|
888
1055
|
await this.engine?.setAudioTrack(trackId);
|
|
@@ -945,25 +1112,52 @@ var PlayerFacade = class {
|
|
|
945
1112
|
// External tracks (for provider-based subtitles)
|
|
946
1113
|
async setExternalVttTrack(lang, vtt) {
|
|
947
1114
|
if (!this.video) return;
|
|
948
|
-
|
|
949
|
-
this.externalVttUrl = URL.createObjectURL(blob);
|
|
950
|
-
this.ensureExternalTrack(this.externalVttUrl, lang);
|
|
1115
|
+
await this.addExternalTextTrack({ label: `External (${lang})`, language: lang, vtt });
|
|
951
1116
|
}
|
|
952
1117
|
setMetadata(meta) {
|
|
953
1118
|
this.metadataSub.next(meta);
|
|
954
1119
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1120
|
+
/** Shows `shown` (its cues go to `captionCues$`, the browser draws none) and turns the other files' subtitles off. */
|
|
1121
|
+
showExternalText(shown) {
|
|
1122
|
+
this.offCueChange?.();
|
|
1123
|
+
this.offCueChange = void 0;
|
|
1124
|
+
for (const text6 of this.externalTexts) {
|
|
1125
|
+
if (text6 !== shown) text6.element.track.mode = "disabled";
|
|
1126
|
+
}
|
|
1127
|
+
if (!shown) {
|
|
1128
|
+
this.captionCuesSub.next([]);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const track = shown.element.track;
|
|
1132
|
+
track.mode = "hidden";
|
|
1133
|
+
const publish = () => this.captionCuesSub.next(Array.from(track.activeCues ?? []));
|
|
1134
|
+
track.addEventListener("cuechange", publish);
|
|
1135
|
+
this.offCueChange = () => track.removeEventListener("cuechange", publish);
|
|
1136
|
+
publish();
|
|
1137
|
+
}
|
|
1138
|
+
clearExternalTextTracks() {
|
|
1139
|
+
const hadSelection = this.textSelection !== void 0 || this.externalTexts.length > 0;
|
|
1140
|
+
this.showExternalText(void 0);
|
|
1141
|
+
for (const text6 of this.externalTexts) {
|
|
1142
|
+
text6.element.remove();
|
|
1143
|
+
URL.revokeObjectURL(text6.url);
|
|
1144
|
+
}
|
|
1145
|
+
this.externalTexts = [];
|
|
1146
|
+
this.textSelection = void 0;
|
|
1147
|
+
if (hadSelection) this.emitTracks();
|
|
1148
|
+
}
|
|
1149
|
+
/** The engine's tracks and the loaded subtitles, the text track picked here marked active. */
|
|
1150
|
+
emitTracks() {
|
|
1151
|
+
const selection = this.textSelection;
|
|
1152
|
+
const engine = selection === void 0 ? this.engineTracks : this.engineTracks.map((track) => track.kind === "text" ? { ...track, active: track.id === selection } : track);
|
|
1153
|
+
const external = this.externalTexts.map((text6) => ({
|
|
1154
|
+
id: text6.id,
|
|
1155
|
+
kind: "text",
|
|
1156
|
+
label: text6.label,
|
|
1157
|
+
...text6.language ? { language: text6.language } : {},
|
|
1158
|
+
active: text6.id === selection
|
|
1159
|
+
}));
|
|
1160
|
+
this.tracksSub.next([...engine, ...external]);
|
|
967
1161
|
}
|
|
968
1162
|
handleEngineEvent(ev) {
|
|
969
1163
|
if (ev.type === "timeupdate") {
|
|
@@ -975,7 +1169,8 @@ var PlayerFacade = class {
|
|
|
975
1169
|
return;
|
|
976
1170
|
}
|
|
977
1171
|
if (ev.type === "tracks") {
|
|
978
|
-
this.
|
|
1172
|
+
this.engineTracks = ev.tracks;
|
|
1173
|
+
this.emitTracks();
|
|
979
1174
|
return;
|
|
980
1175
|
}
|
|
981
1176
|
if (ev.type === "state") {
|
|
@@ -1010,15 +1205,6 @@ var PlayerFacade = class {
|
|
|
1010
1205
|
return null;
|
|
1011
1206
|
}
|
|
1012
1207
|
};
|
|
1013
|
-
function defaultEngineChooser(cfg) {
|
|
1014
|
-
const t = cfg.source.type;
|
|
1015
|
-
const url = t === "url" ? cfg.source.url : "";
|
|
1016
|
-
const looksLikeHls = typeof url === "string" && /\.m3u8($|\?)/i.test(url);
|
|
1017
|
-
const looksLikeDash = typeof url === "string" && /\.mpd($|\?)/i.test(url);
|
|
1018
|
-
const hasDrm = !!cfg.source.drm;
|
|
1019
|
-
if (t === "hls" || t === "dash" || looksLikeHls || looksLikeDash || hasDrm) return new NativeEngine();
|
|
1020
|
-
return new NativeEngine();
|
|
1021
|
-
}
|
|
1022
1208
|
|
|
1023
1209
|
// src/plugins/metadata/providers/local-metadata.provider.ts
|
|
1024
1210
|
var LocalMetadataProvider = class {
|
|
@@ -1030,14 +1216,14 @@ var LocalMetadataProvider = class {
|
|
|
1030
1216
|
const res = await fetch(id);
|
|
1031
1217
|
if (!res.ok) throw new Error(`Metadata fetch failed: ${res.status}`);
|
|
1032
1218
|
const ct = res.headers.get("content-type") ?? "";
|
|
1033
|
-
const
|
|
1034
|
-
if (ct.includes("application/json") || looksLikeJson(
|
|
1219
|
+
const text6 = await res.text();
|
|
1220
|
+
if (ct.includes("application/json") || looksLikeJson(text6)) {
|
|
1035
1221
|
try {
|
|
1036
|
-
return JSON.parse(
|
|
1222
|
+
return JSON.parse(text6);
|
|
1037
1223
|
} catch {
|
|
1038
1224
|
}
|
|
1039
1225
|
}
|
|
1040
|
-
return { description:
|
|
1226
|
+
return { description: text6 };
|
|
1041
1227
|
}
|
|
1042
1228
|
};
|
|
1043
1229
|
function looksLikeJson(t) {
|
|
@@ -1059,6 +1245,73 @@ var LocalSubtitleProvider = class {
|
|
|
1059
1245
|
}
|
|
1060
1246
|
};
|
|
1061
1247
|
|
|
1248
|
+
// src/plugins/subtitles/subtitle-file.ts
|
|
1249
|
+
function decodeSubtitleBytes(bytes) {
|
|
1250
|
+
if (bytes[0] === 255 && bytes[1] === 254) return new TextDecoder("utf-16le").decode(bytes.subarray(2));
|
|
1251
|
+
if (bytes[0] === 254 && bytes[1] === 255) return new TextDecoder("utf-16be").decode(bytes.subarray(2));
|
|
1252
|
+
const body = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? bytes.subarray(3) : bytes;
|
|
1253
|
+
try {
|
|
1254
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
1255
|
+
} catch {
|
|
1256
|
+
return new TextDecoder("windows-1252").decode(body);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
function subtitleTextToVtt(text6) {
|
|
1260
|
+
const lines = text6.replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
1261
|
+
if (/^WEBVTT/.test(lines)) return lines;
|
|
1262
|
+
const body = lines.replace(/(\d{1,2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2").replace(/\{\\[^}]*\}/g, "").trim();
|
|
1263
|
+
return `WEBVTT
|
|
1264
|
+
|
|
1265
|
+
${body}
|
|
1266
|
+
`;
|
|
1267
|
+
}
|
|
1268
|
+
var LANGUAGE_NAMES = {
|
|
1269
|
+
fr: "fr",
|
|
1270
|
+
fre: "fr",
|
|
1271
|
+
fra: "fr",
|
|
1272
|
+
french: "fr",
|
|
1273
|
+
francais: "fr",
|
|
1274
|
+
vf: "fr",
|
|
1275
|
+
vostfr: "fr",
|
|
1276
|
+
en: "en",
|
|
1277
|
+
eng: "en",
|
|
1278
|
+
english: "en",
|
|
1279
|
+
anglais: "en",
|
|
1280
|
+
es: "es",
|
|
1281
|
+
spa: "es",
|
|
1282
|
+
spanish: "es",
|
|
1283
|
+
espanol: "es",
|
|
1284
|
+
de: "de",
|
|
1285
|
+
ger: "de",
|
|
1286
|
+
deu: "de",
|
|
1287
|
+
german: "de",
|
|
1288
|
+
deutsch: "de",
|
|
1289
|
+
it: "it",
|
|
1290
|
+
ita: "it",
|
|
1291
|
+
italian: "it",
|
|
1292
|
+
italiano: "it",
|
|
1293
|
+
pt: "pt",
|
|
1294
|
+
por: "pt",
|
|
1295
|
+
portuguese: "pt",
|
|
1296
|
+
nl: "nl",
|
|
1297
|
+
dut: "nl",
|
|
1298
|
+
nld: "nl",
|
|
1299
|
+
dutch: "nl",
|
|
1300
|
+
ja: "ja",
|
|
1301
|
+
jpn: "ja",
|
|
1302
|
+
japanese: "ja",
|
|
1303
|
+
ar: "ar",
|
|
1304
|
+
ara: "ar",
|
|
1305
|
+
arabic: "ar"
|
|
1306
|
+
};
|
|
1307
|
+
function describeSubtitleFile(fileName) {
|
|
1308
|
+
const base = fileName.replace(/\.(srt|vtt)$/i, "");
|
|
1309
|
+
const parts = base.split(".");
|
|
1310
|
+
const tag = parts.length > 1 ? parts[parts.length - 1].normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase() : "";
|
|
1311
|
+
const language = LANGUAGE_NAMES[tag];
|
|
1312
|
+
return { label: base, ...language ? { language } : {} };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1062
1315
|
// src/utils/cache-store.ts
|
|
1063
1316
|
var DefaultCacheStore = class {
|
|
1064
1317
|
dbp = null;
|
|
@@ -1245,7 +1498,10 @@ var ResiliencePlugin = class {
|
|
|
1245
1498
|
id = "resilience";
|
|
1246
1499
|
attempts = 0;
|
|
1247
1500
|
recovering = false;
|
|
1501
|
+
/** Bumped by every load: a retry only reloads the media that failed, never one loaded since. */
|
|
1502
|
+
loadGeneration = 0;
|
|
1248
1503
|
async onLoadStart(ctx) {
|
|
1504
|
+
this.loadGeneration++;
|
|
1249
1505
|
this.attempts = 0;
|
|
1250
1506
|
this.recovering = false;
|
|
1251
1507
|
if (ctx.config.resilience.healthEndpoint) {
|
|
@@ -1259,6 +1515,7 @@ var ResiliencePlugin = class {
|
|
|
1259
1515
|
if (this.attempts >= max) return;
|
|
1260
1516
|
this.attempts++;
|
|
1261
1517
|
this.recovering = true;
|
|
1518
|
+
const generation = this.loadGeneration;
|
|
1262
1519
|
const min = ctx.config.resilience.backoffMs?.min ?? 500;
|
|
1263
1520
|
const maxMs = ctx.config.resilience.backoffMs?.max ?? 8e3;
|
|
1264
1521
|
const backoff = Math.min(maxMs, min * Math.pow(2, this.attempts - 1));
|
|
@@ -1267,6 +1524,7 @@ var ResiliencePlugin = class {
|
|
|
1267
1524
|
await this.health(ctx);
|
|
1268
1525
|
}
|
|
1269
1526
|
await sleep(backoff);
|
|
1527
|
+
if (generation !== this.loadGeneration) return;
|
|
1270
1528
|
try {
|
|
1271
1529
|
const snap = ctx.facade.snapshot();
|
|
1272
1530
|
await ctx.facade.reload({ startTimeSec: snap.currentTime || void 0 });
|
|
@@ -1326,6 +1584,3821 @@ function createSsaiManifestRewritePlugin() {
|
|
|
1326
1584
|
};
|
|
1327
1585
|
}
|
|
1328
1586
|
|
|
1329
|
-
|
|
1587
|
+
// src/engine/remote-playback.engine.ts
|
|
1588
|
+
function createRemotePlaybackEngineFactory(options) {
|
|
1589
|
+
return {
|
|
1590
|
+
id: "remote",
|
|
1591
|
+
priority: options.priority ?? 100,
|
|
1592
|
+
supports: (config) => options.session() !== null && !!config.source.url,
|
|
1593
|
+
supportsWithCaps: (config) => options.session() !== null && !!config.source.url,
|
|
1594
|
+
create: () => new RemotePlaybackEngine(options)
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
var RemotePlaybackEngine = class {
|
|
1598
|
+
constructor(options) {
|
|
1599
|
+
this.options = options;
|
|
1600
|
+
}
|
|
1601
|
+
options;
|
|
1602
|
+
id = "remote";
|
|
1603
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1604
|
+
session = null;
|
|
1605
|
+
source = null;
|
|
1606
|
+
/** What the device plays, as the app described it. */
|
|
1607
|
+
media = null;
|
|
1608
|
+
startSec = 0;
|
|
1609
|
+
sent = false;
|
|
1610
|
+
/** The device played the media to its end: it closed it, and only takes it again sent anew. */
|
|
1611
|
+
finished = false;
|
|
1612
|
+
durationSec = 0;
|
|
1613
|
+
positionSec = 0;
|
|
1614
|
+
/** Where a converted stream starts in the media: the device counts from there. */
|
|
1615
|
+
offsetSec = 0;
|
|
1616
|
+
/** Furthest position the device reported since the media was sent: an end before any progress is a refusal. */
|
|
1617
|
+
furthestSec = 0;
|
|
1618
|
+
conversionTried = false;
|
|
1619
|
+
lastState = null;
|
|
1620
|
+
timer;
|
|
1621
|
+
polling = false;
|
|
1622
|
+
failures = 0;
|
|
1623
|
+
destroyed = false;
|
|
1624
|
+
async attach(videoEl) {
|
|
1625
|
+
try {
|
|
1626
|
+
videoEl.pause();
|
|
1627
|
+
while (videoEl.firstChild) videoEl.removeChild(videoEl.firstChild);
|
|
1628
|
+
videoEl.removeAttribute("src");
|
|
1629
|
+
videoEl.load();
|
|
1630
|
+
} catch {
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
async load(source, opts) {
|
|
1634
|
+
this.session = this.options.session();
|
|
1635
|
+
if (!this.session) throw new Error("No remote device is selected");
|
|
1636
|
+
this.source = source;
|
|
1637
|
+
this.media = null;
|
|
1638
|
+
this.startSec = Math.max(0, opts?.startTimeSec ?? 0);
|
|
1639
|
+
this.positionSec = this.startSec;
|
|
1640
|
+
this.offsetSec = 0;
|
|
1641
|
+
this.sent = false;
|
|
1642
|
+
this.finished = false;
|
|
1643
|
+
this.furthestSec = 0;
|
|
1644
|
+
this.conversionTried = false;
|
|
1645
|
+
this.lastState = null;
|
|
1646
|
+
this.emit({ type: "timeupdate", currentTime: this.positionSec, duration: this.durationSec });
|
|
1647
|
+
}
|
|
1648
|
+
async play() {
|
|
1649
|
+
const session = this.requireSession();
|
|
1650
|
+
if (this.finished) this.restartAt(0);
|
|
1651
|
+
if (!this.sent) {
|
|
1652
|
+
this.emit({ type: "state", state: "loading" });
|
|
1653
|
+
const media = this.media ?? await this.options.media(this.source, this.startSec);
|
|
1654
|
+
if (this.destroyed) return;
|
|
1655
|
+
try {
|
|
1656
|
+
await this.send(session, media, this.startSec);
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
if (!this.convertible(media) || !(error instanceof RemotePlaybackError)) throw error;
|
|
1659
|
+
this.conversionTried = true;
|
|
1660
|
+
const converted = await this.options.convertedMedia(this.source, this.startSec).catch(() => {
|
|
1661
|
+
throw error;
|
|
1662
|
+
});
|
|
1663
|
+
if (this.destroyed) return;
|
|
1664
|
+
await this.send(session, converted, this.startSec);
|
|
1665
|
+
}
|
|
1666
|
+
this.sent = true;
|
|
1667
|
+
this.startPolling();
|
|
1668
|
+
} else {
|
|
1669
|
+
await session.play();
|
|
1670
|
+
}
|
|
1671
|
+
this.lastState = "playing";
|
|
1672
|
+
this.emit({ type: "state", state: "playing" });
|
|
1673
|
+
}
|
|
1674
|
+
async pause() {
|
|
1675
|
+
if (!this.sent) return;
|
|
1676
|
+
await this.requireSession().pause();
|
|
1677
|
+
this.lastState = "paused";
|
|
1678
|
+
this.emit({ type: "state", state: "paused" });
|
|
1679
|
+
}
|
|
1680
|
+
async seekTo(timeSec) {
|
|
1681
|
+
const target = Math.max(0, timeSec);
|
|
1682
|
+
this.positionSec = target;
|
|
1683
|
+
if (!this.sent || this.finished) {
|
|
1684
|
+
this.restartAt(target);
|
|
1685
|
+
} else if (this.media?.conversion) {
|
|
1686
|
+
const session = this.requireSession();
|
|
1687
|
+
const paused = this.lastState === "paused";
|
|
1688
|
+
await this.send(session, this.media, target);
|
|
1689
|
+
if (paused) await session.pause();
|
|
1690
|
+
} else {
|
|
1691
|
+
await this.requireSession().seek(target);
|
|
1692
|
+
}
|
|
1693
|
+
this.emit({ type: "timeupdate", currentTime: target, duration: this.durationSec });
|
|
1694
|
+
}
|
|
1695
|
+
async setVolume(volume) {
|
|
1696
|
+
if (!this.session) return;
|
|
1697
|
+
await this.session.setVolume(Math.min(1, Math.max(0, volume))).catch(() => void 0);
|
|
1698
|
+
}
|
|
1699
|
+
async setPlaybackRate(_rate) {
|
|
1700
|
+
}
|
|
1701
|
+
async setTextTrack(_trackId) {
|
|
1702
|
+
}
|
|
1703
|
+
async setAudioTrack(_trackId) {
|
|
1704
|
+
}
|
|
1705
|
+
async setVideoTrack(_trackId) {
|
|
1706
|
+
}
|
|
1707
|
+
async getTracks() {
|
|
1708
|
+
return [];
|
|
1709
|
+
}
|
|
1710
|
+
onEvent(cb) {
|
|
1711
|
+
this.listeners.add(cb);
|
|
1712
|
+
return () => this.listeners.delete(cb);
|
|
1713
|
+
}
|
|
1714
|
+
/** Stops following the device; the session stays open for the next media. */
|
|
1715
|
+
async destroy() {
|
|
1716
|
+
this.destroyed = true;
|
|
1717
|
+
if (this.timer) clearInterval(this.timer);
|
|
1718
|
+
this.timer = void 0;
|
|
1719
|
+
this.listeners.clear();
|
|
1720
|
+
}
|
|
1721
|
+
/** Reads the device once; exposed for hosts and tests that do not want to wait for the timer. */
|
|
1722
|
+
async poll() {
|
|
1723
|
+
if (!this.session || !this.sent || this.polling || this.destroyed) return;
|
|
1724
|
+
this.polling = true;
|
|
1725
|
+
try {
|
|
1726
|
+
const status = await this.session.status();
|
|
1727
|
+
if (this.destroyed) return;
|
|
1728
|
+
this.failures = 0;
|
|
1729
|
+
if (status.durationSec && !this.media?.conversion) this.durationSec = status.durationSec;
|
|
1730
|
+
if (status.positionSec !== void 0) this.positionSec = this.offsetSec + status.positionSec;
|
|
1731
|
+
this.furthestSec = Math.max(this.furthestSec, this.positionSec - this.offsetSec);
|
|
1732
|
+
const wasPlaying = this.lastState === "playing" || this.lastState === "buffering";
|
|
1733
|
+
const refused = (status.ended || status.state === "stopped") && this.furthestSec < 1 && !this.source?.live && (status.ended || wasPlaying);
|
|
1734
|
+
if ((refused || status.error) && this.convertible(this.media)) {
|
|
1735
|
+
await this.convertAfterRefusal(status.error);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
this.emit({ type: "timeupdate", currentTime: this.positionSec, duration: this.durationSec });
|
|
1739
|
+
if (status.error) {
|
|
1740
|
+
this.emit({ type: "error", error: { code: "REMOTE_PLAYBACK", message: status.error, fatal: false } });
|
|
1741
|
+
}
|
|
1742
|
+
if (refused) {
|
|
1743
|
+
this.emit({ type: "error", error: { code: "REMOTE_PLAYBACK", message: status.error ?? "The device could not play this media", fatal: false } });
|
|
1744
|
+
this.lastState = status.state;
|
|
1745
|
+
if (this.timer) clearInterval(this.timer);
|
|
1746
|
+
this.timer = void 0;
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const nearEnd = this.durationSec > 0 && this.positionSec >= this.durationSec - 3;
|
|
1750
|
+
let state = null;
|
|
1751
|
+
if (status.state === "playing") state = { type: "state", state: "playing" };
|
|
1752
|
+
else if (status.state === "paused") state = { type: "state", state: "paused" };
|
|
1753
|
+
else if (status.state === "buffering" || status.state === "loading") state = { type: "state", state: "buffering" };
|
|
1754
|
+
else if (status.ended || wasPlaying && nearEnd && !this.source?.live) state = { type: "state", state: "ended" };
|
|
1755
|
+
else if (wasPlaying) state = { type: "state", state: "paused" };
|
|
1756
|
+
if (state?.state === "ended") {
|
|
1757
|
+
this.finished = true;
|
|
1758
|
+
if (this.timer) clearInterval(this.timer);
|
|
1759
|
+
this.timer = void 0;
|
|
1760
|
+
}
|
|
1761
|
+
if (state && status.state !== this.lastState) this.emit(state);
|
|
1762
|
+
this.lastState = status.state;
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
this.failures += 1;
|
|
1765
|
+
if (this.failures === 3) {
|
|
1766
|
+
this.emit({ type: "error", error: { code: "REMOTE_UNREACHABLE", message: error instanceof Error ? error.message : String(error), fatal: false } });
|
|
1767
|
+
}
|
|
1768
|
+
} finally {
|
|
1769
|
+
this.polling = false;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
restartAt(positionSec) {
|
|
1773
|
+
this.finished = false;
|
|
1774
|
+
this.sent = false;
|
|
1775
|
+
this.startSec = positionSec;
|
|
1776
|
+
}
|
|
1777
|
+
/** Sends a media to the device from a position of it; a converted stream starts there and counts from 0. */
|
|
1778
|
+
async send(session, media, startSec) {
|
|
1779
|
+
const conversion = media.conversion;
|
|
1780
|
+
this.durationSec = media.durationSec ?? 0;
|
|
1781
|
+
await session.load(conversion ? { ...media, url: conversion.at(startSec), startSec: 0, ...media.durationSec ? { durationSec: Math.max(0, media.durationSec - startSec) } : {} } : { ...media, startSec });
|
|
1782
|
+
this.media = media;
|
|
1783
|
+
this.offsetSec = conversion ? startSec : 0;
|
|
1784
|
+
this.positionSec = startSec;
|
|
1785
|
+
this.furthestSec = 0;
|
|
1786
|
+
}
|
|
1787
|
+
convertible(media) {
|
|
1788
|
+
return !!this.options.convertedMedia && !!media && !media.conversion && !media.live && !this.conversionTried;
|
|
1789
|
+
}
|
|
1790
|
+
/** The device gave up on the media as it is: it gets it converted, from where it was. */
|
|
1791
|
+
async convertAfterRefusal(reason) {
|
|
1792
|
+
this.conversionTried = true;
|
|
1793
|
+
const position = this.positionSec;
|
|
1794
|
+
try {
|
|
1795
|
+
const converted = await this.options.convertedMedia(this.source, position);
|
|
1796
|
+
if (this.destroyed) return;
|
|
1797
|
+
await this.send(this.requireSession(), converted, position);
|
|
1798
|
+
this.lastState = "playing";
|
|
1799
|
+
} catch {
|
|
1800
|
+
if (this.destroyed) return;
|
|
1801
|
+
this.emit({ type: "error", error: { code: "REMOTE_PLAYBACK", message: reason ?? "The device could not play this media", fatal: false } });
|
|
1802
|
+
if (this.timer) clearInterval(this.timer);
|
|
1803
|
+
this.timer = void 0;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
startPolling() {
|
|
1807
|
+
if (this.timer || this.destroyed) return;
|
|
1808
|
+
this.timer = setInterval(() => void this.poll(), this.options.pollMs ?? 1e3);
|
|
1809
|
+
}
|
|
1810
|
+
requireSession() {
|
|
1811
|
+
if (!this.session || this.destroyed) throw new Error("No remote device is selected");
|
|
1812
|
+
return this.session;
|
|
1813
|
+
}
|
|
1814
|
+
emit(event) {
|
|
1815
|
+
for (const listener of this.listeners) listener(event);
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
// src/domain/audio-library.ts
|
|
1820
|
+
function createAudioLibrarySnapshot(tracks, playlists = []) {
|
|
1821
|
+
const trackCopies = tracks.map(cloneAudioTrack);
|
|
1822
|
+
return {
|
|
1823
|
+
tracks: trackCopies,
|
|
1824
|
+
albums: groupAudioAlbums(trackCopies),
|
|
1825
|
+
artists: groupAudioArtists(trackCopies),
|
|
1826
|
+
genres: groupAudioGenres(trackCopies),
|
|
1827
|
+
playlists: playlists.map((playlist) => ({ ...playlist, trackIds: [...playlist.trackIds] }))
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
function searchAudioLibrarySnapshot(snapshot, searchTerm) {
|
|
1831
|
+
const query = normalizeAudioSearchText(searchTerm);
|
|
1832
|
+
if (!query) {
|
|
1833
|
+
return {
|
|
1834
|
+
...cloneAudioLibrarySnapshot(snapshot),
|
|
1835
|
+
active: false,
|
|
1836
|
+
query,
|
|
1837
|
+
directTrackIds: [],
|
|
1838
|
+
matchedTrackIds: [],
|
|
1839
|
+
matchedAlbumIds: [],
|
|
1840
|
+
matchedArtistIds: [],
|
|
1841
|
+
matchedGenreIds: [],
|
|
1842
|
+
matchedPlaylistIds: []
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
const directTrackIds = ids(snapshot.tracks.filter((track) => metadataMatchesQuery(audioTrackSearchValues(track), query)));
|
|
1846
|
+
const matchedAlbumIds = ids(snapshot.albums.filter((album) => metadataMatchesQuery(audioAlbumSearchValues(album), query)));
|
|
1847
|
+
const matchedArtistIds = ids(snapshot.artists.filter((artist) => metadataMatchesQuery(audioArtistSearchValues(artist), query)));
|
|
1848
|
+
const matchedGenreIds = ids(snapshot.genres.filter((genre) => metadataMatchesQuery(audioGenreSearchValues(genre), query)));
|
|
1849
|
+
const matchedPlaylistIds = ids(snapshot.playlists.filter((playlist) => metadataMatchesQuery(audioPlaylistSearchValues(playlist), query)));
|
|
1850
|
+
const matchedTrackIds = new Set(directTrackIds);
|
|
1851
|
+
for (const album of snapshot.albums) {
|
|
1852
|
+
if (matchedAlbumIds.includes(album.id)) addTrackIds(matchedTrackIds, album.tracks);
|
|
1853
|
+
}
|
|
1854
|
+
for (const artist of snapshot.artists) {
|
|
1855
|
+
if (matchedArtistIds.includes(artist.id)) addTrackIds(matchedTrackIds, artist.tracks);
|
|
1856
|
+
}
|
|
1857
|
+
for (const genre of snapshot.genres) {
|
|
1858
|
+
if (matchedGenreIds.includes(genre.id)) addTrackIds(matchedTrackIds, genre.tracks);
|
|
1859
|
+
}
|
|
1860
|
+
for (const playlist of snapshot.playlists) {
|
|
1861
|
+
if (matchedPlaylistIds.includes(playlist.id)) {
|
|
1862
|
+
playlist.trackIds.forEach((trackId) => matchedTrackIds.add(trackId));
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
const matchedTracks = snapshot.tracks.filter((track) => matchedTrackIds.has(track.id));
|
|
1866
|
+
const matchedAlbums = snapshot.albums.filter((album) => matchedAlbumIds.includes(album.id) || album.tracks.some((track) => matchedTrackIds.has(track.id)));
|
|
1867
|
+
const matchedArtists = snapshot.artists.filter((artist) => matchedArtistIds.includes(artist.id) || artist.tracks.some((track) => matchedTrackIds.has(track.id)));
|
|
1868
|
+
const matchedGenres = snapshot.genres.filter((genre) => matchedGenreIds.includes(genre.id) || genre.tracks.some((track) => matchedTrackIds.has(track.id)));
|
|
1869
|
+
const matchedPlaylists = snapshot.playlists.filter((playlist) => matchedPlaylistIds.includes(playlist.id) || !matchedPlaylistIds.length && playlist.trackIds.some((trackId) => matchedTrackIds.has(trackId)));
|
|
1870
|
+
return {
|
|
1871
|
+
active: true,
|
|
1872
|
+
query,
|
|
1873
|
+
tracks: matchedTracks.map(cloneAudioTrack),
|
|
1874
|
+
albums: matchedAlbums.map(cloneAudioAlbum),
|
|
1875
|
+
artists: matchedArtists.map(cloneAudioArtist),
|
|
1876
|
+
genres: matchedGenres.map(cloneAudioGenre),
|
|
1877
|
+
playlists: matchedPlaylists.map(cloneAudioPlaylist),
|
|
1878
|
+
directTrackIds,
|
|
1879
|
+
matchedTrackIds: [...matchedTrackIds],
|
|
1880
|
+
matchedAlbumIds,
|
|
1881
|
+
matchedArtistIds,
|
|
1882
|
+
matchedGenreIds,
|
|
1883
|
+
matchedPlaylistIds
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
function normalizeAudioSearchText(value) {
|
|
1887
|
+
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
1888
|
+
}
|
|
1889
|
+
function audioTrackMatchesSearch(track, searchTerm) {
|
|
1890
|
+
const query = normalizeAudioSearchText(searchTerm);
|
|
1891
|
+
return !!query && metadataMatchesQuery(audioTrackSearchValues(track), query);
|
|
1892
|
+
}
|
|
1893
|
+
function groupAudioAlbums(tracks) {
|
|
1894
|
+
const groups = groupByMetadata(
|
|
1895
|
+
tracks,
|
|
1896
|
+
(track) => track.album,
|
|
1897
|
+
(track) => track.albumArtist ? `${metadataGroupKey(track.albumArtist)}\0` : ""
|
|
1898
|
+
);
|
|
1899
|
+
const titleCounts = /* @__PURE__ */ new Map();
|
|
1900
|
+
for (const [albumTitle] of groups) {
|
|
1901
|
+
const slug = slugifyAudioLibraryId(albumTitle);
|
|
1902
|
+
titleCounts.set(slug, (titleCounts.get(slug) ?? 0) + 1);
|
|
1903
|
+
}
|
|
1904
|
+
const albums = groups.map(([albumTitle, albumTracks]) => {
|
|
1905
|
+
const albumArtist = firstDefined(albumTracks.map((track) => track.albumArtist?.trim() || void 0));
|
|
1906
|
+
const slug = slugifyAudioLibraryId(albumTitle);
|
|
1907
|
+
return {
|
|
1908
|
+
// Same-title albums of different artists get the artist in their id to stay distinct.
|
|
1909
|
+
id: albumArtist && (titleCounts.get(slug) ?? 0) > 1 ? `album:${slug}--${slugifyAudioLibraryId(albumArtist)}` : `album:${slug}`,
|
|
1910
|
+
title: albumTitle,
|
|
1911
|
+
artist: uniqueValues(albumTracks.map((track) => track.albumArtist ?? track.artist)).join(", ") || void 0,
|
|
1912
|
+
year: mostFrequentYear(albumTracks),
|
|
1913
|
+
artwork: firstDefined(albumTracks.map((track) => track.artwork?.[0]?.src)),
|
|
1914
|
+
tracks: albumTracks.map(cloneAudioTrack)
|
|
1915
|
+
};
|
|
1916
|
+
});
|
|
1917
|
+
return albums.sort((left, right) => (right.year ?? Number.NEGATIVE_INFINITY) - (left.year ?? Number.NEGATIVE_INFINITY) || left.title.localeCompare(right.title, void 0, { sensitivity: "base", numeric: true }));
|
|
1918
|
+
}
|
|
1919
|
+
function groupAudioArtists(tracks) {
|
|
1920
|
+
return groupByMetadata(tracks, (track) => track.albumArtist ?? track.artist).map(([artistName, artistTracks]) => ({
|
|
1921
|
+
id: `artist:${slugifyAudioLibraryId(artistName)}`,
|
|
1922
|
+
name: artistName,
|
|
1923
|
+
artwork: firstDefined(artistTracks.map((track) => track.artwork?.[0]?.src)),
|
|
1924
|
+
tracks: artistTracks.map(cloneAudioTrack)
|
|
1925
|
+
}));
|
|
1926
|
+
}
|
|
1927
|
+
function groupAudioGenres(tracks) {
|
|
1928
|
+
return groupByMetadata(tracks, (track) => track.genre).map(([genreName, genreTracks]) => ({
|
|
1929
|
+
id: `genre:${slugifyAudioLibraryId(genreName)}`,
|
|
1930
|
+
name: genreName,
|
|
1931
|
+
tracks: genreTracks.map(cloneAudioTrack)
|
|
1932
|
+
}));
|
|
1933
|
+
}
|
|
1934
|
+
function cloneAudioLibrarySnapshot(snapshot) {
|
|
1935
|
+
return {
|
|
1936
|
+
tracks: snapshot.tracks.map(cloneAudioTrack),
|
|
1937
|
+
albums: snapshot.albums.map(cloneAudioAlbum),
|
|
1938
|
+
artists: snapshot.artists.map(cloneAudioArtist),
|
|
1939
|
+
genres: snapshot.genres.map(cloneAudioGenre),
|
|
1940
|
+
playlists: snapshot.playlists.map(cloneAudioPlaylist)
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
function cloneAudioAlbum(album) {
|
|
1944
|
+
return {
|
|
1945
|
+
...album,
|
|
1946
|
+
tracks: album.tracks.map(cloneAudioTrack)
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function cloneAudioArtist(artist) {
|
|
1950
|
+
return {
|
|
1951
|
+
...artist,
|
|
1952
|
+
tracks: artist.tracks.map(cloneAudioTrack)
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
function cloneAudioGenre(genre) {
|
|
1956
|
+
return {
|
|
1957
|
+
...genre,
|
|
1958
|
+
tracks: genre.tracks.map(cloneAudioTrack)
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function cloneAudioPlaylist(playlist) {
|
|
1962
|
+
return {
|
|
1963
|
+
...playlist,
|
|
1964
|
+
trackIds: [...playlist.trackIds]
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
function cloneAudioTrack(track) {
|
|
1968
|
+
return {
|
|
1969
|
+
...track,
|
|
1970
|
+
artwork: track.artwork?.map((artwork) => ({ ...artwork }))
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
function groupByMetadata(tracks, pickValue, scope = () => "") {
|
|
1974
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1975
|
+
for (const track of tracks) {
|
|
1976
|
+
const name = pickValue(track)?.trim();
|
|
1977
|
+
if (!name) continue;
|
|
1978
|
+
const key = scope(track) + (metadataGroupKey(name) || name);
|
|
1979
|
+
const group = groups.get(key) ?? { names: /* @__PURE__ */ new Map(), tracks: [] };
|
|
1980
|
+
group.names.set(name, (group.names.get(name) ?? 0) + 1);
|
|
1981
|
+
group.tracks.push(track);
|
|
1982
|
+
groups.set(key, group);
|
|
1983
|
+
}
|
|
1984
|
+
return [...groups.values()].map((group) => {
|
|
1985
|
+
const name = [...group.names].sort((left, right) => right[1] - left[1] || casingQuality(right[0]) - casingQuality(left[0]))[0][0];
|
|
1986
|
+
return [name, group.tracks];
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
function casingQuality(value) {
|
|
1990
|
+
return value !== value.toLowerCase() && value !== value.toUpperCase() ? 1 : 0;
|
|
1991
|
+
}
|
|
1992
|
+
function metadataGroupKey(value) {
|
|
1993
|
+
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/&/g, "and").replace(/[^\p{L}\p{N}]+/gu, "");
|
|
1994
|
+
}
|
|
1995
|
+
function mostFrequentYear(tracks) {
|
|
1996
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1997
|
+
for (const track of tracks) {
|
|
1998
|
+
if (track.year !== void 0) counts.set(track.year, (counts.get(track.year) ?? 0) + 1);
|
|
1999
|
+
}
|
|
2000
|
+
return [...counts].sort((left, right) => right[1] - left[1] || left[0] - right[0])[0]?.[0];
|
|
2001
|
+
}
|
|
2002
|
+
function uniqueValues(values) {
|
|
2003
|
+
return [...new Set(values.flatMap((value) => {
|
|
2004
|
+
const cleanValue = value?.trim();
|
|
2005
|
+
return cleanValue ? [cleanValue] : [];
|
|
2006
|
+
}))];
|
|
2007
|
+
}
|
|
2008
|
+
function firstDefined(values) {
|
|
2009
|
+
return values.find((value) => value !== void 0);
|
|
2010
|
+
}
|
|
2011
|
+
function addTrackIds(target, tracks) {
|
|
2012
|
+
tracks.forEach((track) => target.add(track.id));
|
|
2013
|
+
}
|
|
2014
|
+
function ids(items) {
|
|
2015
|
+
return items.map((item) => item.id);
|
|
2016
|
+
}
|
|
2017
|
+
function audioTrackSearchValues(track) {
|
|
2018
|
+
return [
|
|
2019
|
+
track.title,
|
|
2020
|
+
track.artist,
|
|
2021
|
+
track.albumArtist,
|
|
2022
|
+
track.album,
|
|
2023
|
+
track.genre,
|
|
2024
|
+
track.subtitle,
|
|
2025
|
+
track.durationSec,
|
|
2026
|
+
track.trackNo,
|
|
2027
|
+
track.discNo,
|
|
2028
|
+
track.year
|
|
2029
|
+
];
|
|
2030
|
+
}
|
|
2031
|
+
function audioAlbumSearchValues(album) {
|
|
2032
|
+
return [
|
|
2033
|
+
album.title,
|
|
2034
|
+
album.artist,
|
|
2035
|
+
album.year
|
|
2036
|
+
];
|
|
2037
|
+
}
|
|
2038
|
+
function audioArtistSearchValues(artist) {
|
|
2039
|
+
return [
|
|
2040
|
+
artist.name
|
|
2041
|
+
];
|
|
2042
|
+
}
|
|
2043
|
+
function audioGenreSearchValues(genre) {
|
|
2044
|
+
return [
|
|
2045
|
+
genre.name
|
|
2046
|
+
];
|
|
2047
|
+
}
|
|
2048
|
+
function audioPlaylistSearchValues(playlist) {
|
|
2049
|
+
return [
|
|
2050
|
+
playlist.name,
|
|
2051
|
+
playlist.description
|
|
2052
|
+
];
|
|
2053
|
+
}
|
|
2054
|
+
function metadataMatchesQuery(values, query) {
|
|
2055
|
+
return values.some((value) => {
|
|
2056
|
+
if (value === void 0) return false;
|
|
2057
|
+
return normalizeAudioSearchText(String(value)).includes(query);
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
function slugifyAudioLibraryId(value) {
|
|
2061
|
+
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// src/domain/audio-metadata-harmony.ts
|
|
2065
|
+
var GENRE_KEY_ALIASES = {
|
|
2066
|
+
rb: "rnb",
|
|
2067
|
+
randb: "rnb",
|
|
2068
|
+
rhythmandblues: "rnb",
|
|
2069
|
+
rhythmnblues: "rnb",
|
|
2070
|
+
hiphoprap: "hiphop",
|
|
2071
|
+
raphiphop: "hiphop",
|
|
2072
|
+
rocknroll: "rockandroll",
|
|
2073
|
+
rockroll: "rockandroll",
|
|
2074
|
+
ost: "soundtrack",
|
|
2075
|
+
originalsoundtrack: "soundtrack",
|
|
2076
|
+
worldmusic: "world"
|
|
2077
|
+
};
|
|
2078
|
+
var TRACK_NUMBER_PREFIX = /^\s*(\d{1,3})\s*(?:[.)_]|-(?!\d))\s*(?=[\p{L}(["'¿¡])/u;
|
|
2079
|
+
var DISC_SUFFIX = /\s*[([]?\s*(?:cd|disc|disk|disque)\s*(\d{1,2})\s*[)\]]?\s*$/iu;
|
|
2080
|
+
function harmonizeAudioMetadata(tracks) {
|
|
2081
|
+
const corrections = { artists: 0, albums: 0, genres: 0, titles: 0, filled: 0 };
|
|
2082
|
+
const corrected = /* @__PURE__ */ new Set();
|
|
2083
|
+
const note = (track, kind) => {
|
|
2084
|
+
corrections[kind] += 1;
|
|
2085
|
+
corrected.add(track.id);
|
|
2086
|
+
};
|
|
2087
|
+
const working = tracks.map((original) => {
|
|
2088
|
+
const track = { ...original };
|
|
2089
|
+
const artist = cleanAudioArtistName(track.artist);
|
|
2090
|
+
const albumArtist = cleanAudioArtistName(track.albumArtist);
|
|
2091
|
+
if (artist !== tidy(track.artist) || albumArtist !== tidy(track.albumArtist)) note(track, "artists");
|
|
2092
|
+
track.artist = artist;
|
|
2093
|
+
track.albumArtist = albumArtist;
|
|
2094
|
+
const album = tidy(track.album);
|
|
2095
|
+
const disc = album ? DISC_SUFFIX.exec(album) : null;
|
|
2096
|
+
if (album && disc && album.slice(0, disc.index).trim()) {
|
|
2097
|
+
track.album = album.slice(0, disc.index).trim();
|
|
2098
|
+
if (track.discNo === void 0) track.discNo = Number(disc[1]);
|
|
2099
|
+
note(track, "albums");
|
|
2100
|
+
} else {
|
|
2101
|
+
track.album = album;
|
|
2102
|
+
}
|
|
2103
|
+
const genre = tidy(track.genre);
|
|
2104
|
+
track.genre = genre;
|
|
2105
|
+
const title = tidy(track.title) ?? track.title;
|
|
2106
|
+
const numbered = TRACK_NUMBER_PREFIX.exec(title);
|
|
2107
|
+
if (numbered && title.slice(numbered[0].length).trim()) {
|
|
2108
|
+
track.title = title.slice(numbered[0].length).trim();
|
|
2109
|
+
if (track.trackNo === void 0) track.trackNo = Number(numbered[1]);
|
|
2110
|
+
note(track, "titles");
|
|
2111
|
+
} else {
|
|
2112
|
+
track.title = title;
|
|
2113
|
+
}
|
|
2114
|
+
return track;
|
|
2115
|
+
});
|
|
2116
|
+
const artistNames = preferredSpellings(working.flatMap((track) => [track.artist, track.albumArtist]), artistKey);
|
|
2117
|
+
for (const track of working) {
|
|
2118
|
+
const artist = track.artist && artistNames.get(artistKey(track.artist));
|
|
2119
|
+
const albumArtist = track.albumArtist && artistNames.get(artistKey(track.albumArtist));
|
|
2120
|
+
if (artist && artist !== track.artist || albumArtist && albumArtist !== track.albumArtist) note(track, "artists");
|
|
2121
|
+
if (artist) track.artist = artist;
|
|
2122
|
+
if (albumArtist) track.albumArtist = albumArtist;
|
|
2123
|
+
}
|
|
2124
|
+
for (const track of working) {
|
|
2125
|
+
const separator = track.title.indexOf(" - ");
|
|
2126
|
+
if (separator <= 0) continue;
|
|
2127
|
+
const prefix = artistNames.get(artistKey(track.title.slice(0, separator)));
|
|
2128
|
+
const rest = track.title.slice(separator + 3).trim();
|
|
2129
|
+
if (!prefix || !rest) continue;
|
|
2130
|
+
if (track.artist && artistKey(track.artist) !== artistKey(prefix)) continue;
|
|
2131
|
+
track.title = rest;
|
|
2132
|
+
note(track, "titles");
|
|
2133
|
+
if (!track.artist) track.artist = prefix;
|
|
2134
|
+
}
|
|
2135
|
+
const genreNames = preferredSpellings(working.map((track) => track.genre), genreKey, readableCase);
|
|
2136
|
+
for (const track of working) {
|
|
2137
|
+
const genre = track.genre && genreNames.get(genreKey(track.genre));
|
|
2138
|
+
if (genre && genre !== track.genre) {
|
|
2139
|
+
track.genre = genre;
|
|
2140
|
+
note(track, "genres");
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
for (const albumTracks of albumGroups(working)) {
|
|
2144
|
+
const title = preferredSpellings(albumTracks.map((track) => track.album), metadataKey).get(metadataKey(albumTracks[0].album ?? ""));
|
|
2145
|
+
const albumArtist = mostFrequent(albumTracks.map((track) => track.albumArtist), artistKey);
|
|
2146
|
+
const year = mostFrequent(albumTracks.map((track) => track.year), String);
|
|
2147
|
+
const genre = mostFrequent(albumTracks.map((track) => track.genre), genreKey);
|
|
2148
|
+
for (const track of albumTracks) {
|
|
2149
|
+
if (title && track.album !== title) {
|
|
2150
|
+
track.album = title;
|
|
2151
|
+
note(track, "albums");
|
|
2152
|
+
}
|
|
2153
|
+
if (albumArtist && !track.albumArtist) {
|
|
2154
|
+
track.albumArtist = albumArtist;
|
|
2155
|
+
note(track, "filled");
|
|
2156
|
+
}
|
|
2157
|
+
if (year !== void 0 && track.year === void 0) {
|
|
2158
|
+
track.year = year;
|
|
2159
|
+
note(track, "filled");
|
|
2160
|
+
}
|
|
2161
|
+
if (genre && !track.genre) {
|
|
2162
|
+
track.genre = genre;
|
|
2163
|
+
note(track, "filled");
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return { tracks: working, corrections, correctedTracks: corrected.size };
|
|
2168
|
+
}
|
|
2169
|
+
function cleanAudioArtistName(value) {
|
|
2170
|
+
let name = tidy(value);
|
|
2171
|
+
if (!name) return void 0;
|
|
2172
|
+
const numbered = TRACK_NUMBER_PREFIX.exec(name);
|
|
2173
|
+
if (numbered && name.slice(numbered[0].length).trim()) name = name.slice(numbered[0].length).trim();
|
|
2174
|
+
if (name.includes("_") && !name.includes(" ")) name = name.replace(/_+/g, " ").trim();
|
|
2175
|
+
return name || void 0;
|
|
2176
|
+
}
|
|
2177
|
+
function mediaMetadataKey(value) {
|
|
2178
|
+
return metadataKey(value);
|
|
2179
|
+
}
|
|
2180
|
+
function metadataKey(value) {
|
|
2181
|
+
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/&/g, "and").replace(/[^\p{L}\p{N}]+/gu, "");
|
|
2182
|
+
}
|
|
2183
|
+
function artistKey(value) {
|
|
2184
|
+
const key = metadataKey(value.replace(/^\s*the\s+/i, ""));
|
|
2185
|
+
return key || metadataKey(value);
|
|
2186
|
+
}
|
|
2187
|
+
function genreKey(value) {
|
|
2188
|
+
const key = metadataKey(value);
|
|
2189
|
+
return GENRE_KEY_ALIASES[key] ?? key;
|
|
2190
|
+
}
|
|
2191
|
+
function albumGroups(tracks) {
|
|
2192
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
2193
|
+
for (const track of tracks) {
|
|
2194
|
+
if (!track.album) continue;
|
|
2195
|
+
const key = metadataKey(track.album);
|
|
2196
|
+
if (!key) continue;
|
|
2197
|
+
byTitle.set(key, [...byTitle.get(key) ?? [], track]);
|
|
2198
|
+
}
|
|
2199
|
+
return [...byTitle.values()].flatMap((titleTracks) => {
|
|
2200
|
+
const albumArtistKeys = new Set(titleTracks.flatMap((track) => track.albumArtist ? [artistKey(track.albumArtist)] : []));
|
|
2201
|
+
if (albumArtistKeys.size <= 1) return [titleTracks];
|
|
2202
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2203
|
+
for (const track of titleTracks) {
|
|
2204
|
+
const key = track.albumArtist ? artistKey(track.albumArtist) : track.artist ? artistKey(track.artist) : "";
|
|
2205
|
+
groups.set(key, [...groups.get(key) ?? [], track]);
|
|
2206
|
+
}
|
|
2207
|
+
return [...groups.values()];
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
function preferredSpellings(values, keyOf, finish = (value) => value) {
|
|
2211
|
+
const variants = /* @__PURE__ */ new Map();
|
|
2212
|
+
for (const value of values) {
|
|
2213
|
+
if (!value) continue;
|
|
2214
|
+
const key = keyOf(value);
|
|
2215
|
+
if (!key) continue;
|
|
2216
|
+
const counts = variants.get(key) ?? /* @__PURE__ */ new Map();
|
|
2217
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
2218
|
+
variants.set(key, counts);
|
|
2219
|
+
}
|
|
2220
|
+
return new Map([...variants].map(([key, counts]) => {
|
|
2221
|
+
const best = [...counts].sort(([left, leftCount], [right, rightCount]) => rightCount - leftCount || spellingQuality(right) - spellingQuality(left) || left.localeCompare(right))[0][0];
|
|
2222
|
+
return [key, finish(best)];
|
|
2223
|
+
}));
|
|
2224
|
+
}
|
|
2225
|
+
function mostFrequent(values, keyOf) {
|
|
2226
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2227
|
+
for (const value of values) {
|
|
2228
|
+
if (value === void 0 || value === "") continue;
|
|
2229
|
+
const key = keyOf(value);
|
|
2230
|
+
const entry = counts.get(key);
|
|
2231
|
+
counts.set(key, { value: entry?.value ?? value, count: (entry?.count ?? 0) + 1 });
|
|
2232
|
+
}
|
|
2233
|
+
return [...counts.values()].sort((left, right) => right.count - left.count)[0]?.value;
|
|
2234
|
+
}
|
|
2235
|
+
function spellingQuality(value) {
|
|
2236
|
+
const acronym = value === value.toUpperCase() && value.replace(/[^\p{L}]/gu, "").length <= 4;
|
|
2237
|
+
const mixedCase = acronym || value !== value.toLowerCase() && value !== value.toUpperCase() ? 2 : 0;
|
|
2238
|
+
const accents = value.normalize("NFD").length !== value.length ? 1 : 0;
|
|
2239
|
+
return mixedCase + accents;
|
|
2240
|
+
}
|
|
2241
|
+
function readableCase(value) {
|
|
2242
|
+
const letters = value.replace(/[^\p{L}]/gu, "");
|
|
2243
|
+
if (letters.length <= 3) return value;
|
|
2244
|
+
if (value !== value.toLowerCase() && value !== value.toUpperCase()) return value;
|
|
2245
|
+
return value.toLowerCase().replace(/(^|[\s\-/&(])(\p{L})/gu, (_, separator, letter) => separator + letter.toUpperCase());
|
|
2246
|
+
}
|
|
2247
|
+
function tidy(value) {
|
|
2248
|
+
const text6 = value?.replace(/\s+/g, " ").trim().replace(/^["'](.*)["']$/, "$1").trim();
|
|
2249
|
+
return text6 || void 0;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// src/domain/audio-identification.ts
|
|
2253
|
+
var AUDIO_IDENTIFICATION_VERSION = 1;
|
|
2254
|
+
var AUDIO_IDENTIFICATION_RETRY_MS = { unmatched: 30 * 24 * 36e5, error: 36e5 };
|
|
2255
|
+
var MIN_SEARCH_SCORE = 90;
|
|
2256
|
+
var DURATION_TOLERANCE_SEC = 4;
|
|
2257
|
+
var MIN_FINGERPRINT_SCORE = 0.85;
|
|
2258
|
+
var FINGERPRINT_DURATION_TOLERANCE_SEC = 7;
|
|
2259
|
+
function audioIdentificationDoubts(input) {
|
|
2260
|
+
const inferred = new Set(input.inferredFields ?? []);
|
|
2261
|
+
return ["title", "artist", "album"].filter((field) => inferred.has(field) || !input[field]);
|
|
2262
|
+
}
|
|
2263
|
+
function needsAudioIdentification(previous, fileKey, now, methods = ["text"]) {
|
|
2264
|
+
if (!previous || previous.fileKey !== fileKey) return true;
|
|
2265
|
+
if (previous.status === "trusted" || previous.status === "matched") return false;
|
|
2266
|
+
if (previous.status === "error") return now - previous.checkedAt >= AUDIO_IDENTIFICATION_RETRY_MS.error;
|
|
2267
|
+
if (methods.includes("fingerprint") && previous.method !== "fingerprint") return true;
|
|
2268
|
+
if (previous.version < AUDIO_IDENTIFICATION_VERSION) return true;
|
|
2269
|
+
return now - previous.checkedAt >= AUDIO_IDENTIFICATION_RETRY_MS.unmatched;
|
|
2270
|
+
}
|
|
2271
|
+
function pickAcoustIdRecording(input, results) {
|
|
2272
|
+
const inferred = new Set(input.inferredFields ?? []);
|
|
2273
|
+
const titleKey = !inferred.has("title") ? mediaMetadataKey(input.title) : "";
|
|
2274
|
+
const best = [...results].filter((result) => result.score >= MIN_FINGERPRINT_SCORE && result.recordings.length).sort((left, right) => right.score - left.score)[0];
|
|
2275
|
+
if (!best) return null;
|
|
2276
|
+
const recordings = best.recordings.filter((recording2) => !input.durationSec || !recording2.durationSec || Math.abs(input.durationSec - recording2.durationSec) <= FINGERPRINT_DURATION_TOLERANCE_SEC);
|
|
2277
|
+
const recording = recordings.find((item) => titleKey && item.title && mediaMetadataKey(item.title) === titleKey) ?? recordings[0];
|
|
2278
|
+
return recording ? { recordingId: recording.id, confidence: Math.min(1, best.score) } : null;
|
|
2279
|
+
}
|
|
2280
|
+
function matchFingerprintedRecording(input, recording, confidence) {
|
|
2281
|
+
if (!recording) return { status: "unmatched", method: "fingerprint", confidence: 0, fields: {} };
|
|
2282
|
+
const inferred = new Set(input.inferredFields ?? []);
|
|
2283
|
+
const release = pickRelease({ ...input, artist: inferred.has("artist") || !input.artist ? recording.artist : input.artist }, recording, [recording]);
|
|
2284
|
+
const fields = { title: recording.title };
|
|
2285
|
+
if (recording.artist) fields.artist = recording.artist;
|
|
2286
|
+
addReleaseFields(fields, release);
|
|
2287
|
+
return { status: "matched", method: "fingerprint", recordingId: recording.id, releaseId: release?.id, confidence, fields };
|
|
2288
|
+
}
|
|
2289
|
+
function matchMusicBrainzRecording(input, recordings) {
|
|
2290
|
+
const titleKey = mediaMetadataKey(input.title);
|
|
2291
|
+
const artistKey2 = input.artist ? mediaMetadataKey(input.artist) : "";
|
|
2292
|
+
const candidates = !artistKey2 ? [] : recordings.filter((recording) => {
|
|
2293
|
+
if (recording.score < MIN_SEARCH_SCORE || !titleKey || mediaMetadataKey(recording.title) !== titleKey) return false;
|
|
2294
|
+
if (input.durationSec && recording.durationSec && Math.abs(input.durationSec - recording.durationSec) > DURATION_TOLERANCE_SEC) return false;
|
|
2295
|
+
return sameArtist(artistKey2, recording.artist);
|
|
2296
|
+
});
|
|
2297
|
+
if (!candidates.length) return { status: "unmatched", method: "text", confidence: 0, fields: {} };
|
|
2298
|
+
const best = [...candidates].sort((left, right) => right.score - left.score || durationGap(input, left) - durationGap(input, right))[0];
|
|
2299
|
+
const release = pickRelease(input, best, candidates);
|
|
2300
|
+
const fields = { title: best.title };
|
|
2301
|
+
if (best.artist) fields.artist = best.artist;
|
|
2302
|
+
addReleaseFields(fields, release);
|
|
2303
|
+
return {
|
|
2304
|
+
status: "matched",
|
|
2305
|
+
method: "text",
|
|
2306
|
+
recordingId: best.id,
|
|
2307
|
+
releaseId: release?.id,
|
|
2308
|
+
confidence: Math.min(1, best.score / 100),
|
|
2309
|
+
fields
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
function applyAudioIdentification(item, identification) {
|
|
2313
|
+
if (identification?.status !== "matched" || !identification.fields) return item;
|
|
2314
|
+
const inferred = new Set(item.inferredFields ?? []);
|
|
2315
|
+
const next = { ...item };
|
|
2316
|
+
for (const [field, value] of Object.entries(identification.fields)) {
|
|
2317
|
+
if (value === void 0 || item[field] !== void 0 && item[field] !== "" && !inferred.has(field)) continue;
|
|
2318
|
+
next[field] = value;
|
|
2319
|
+
}
|
|
2320
|
+
return next;
|
|
2321
|
+
}
|
|
2322
|
+
function addReleaseFields(fields, release) {
|
|
2323
|
+
if (!release) return;
|
|
2324
|
+
fields.album = release.title;
|
|
2325
|
+
if (release.artist) fields.albumArtist = release.artist;
|
|
2326
|
+
if (release.year) fields.year = release.year;
|
|
2327
|
+
if (release.trackNo) fields.trackNo = release.trackNo;
|
|
2328
|
+
}
|
|
2329
|
+
function pickRelease(input, best, candidates) {
|
|
2330
|
+
const releases = [best, ...candidates.filter((candidate) => candidate !== best)].flatMap((recording) => recording.releases);
|
|
2331
|
+
if (input.album) {
|
|
2332
|
+
const albumKey = mediaMetadataKey(input.album);
|
|
2333
|
+
return releases.find((release) => mediaMetadataKey(release.title) === albumKey);
|
|
2334
|
+
}
|
|
2335
|
+
const artistKey2 = mediaMetadataKey(input.artist ?? best.artist ?? "");
|
|
2336
|
+
return releases.filter((release) => release.status === "Official" && release.primaryType === "Album" && sameArtist(artistKey2, release.artist)).sort((left, right) => (left.year ?? Number.MAX_SAFE_INTEGER) - (right.year ?? Number.MAX_SAFE_INTEGER))[0];
|
|
2337
|
+
}
|
|
2338
|
+
function sameArtist(artistKey2, candidate) {
|
|
2339
|
+
if (!artistKey2 || !candidate) return false;
|
|
2340
|
+
const candidateKey = mediaMetadataKey(candidate);
|
|
2341
|
+
return candidateKey === artistKey2 || candidateKey.startsWith(artistKey2) || artistKey2.startsWith(candidateKey);
|
|
2342
|
+
}
|
|
2343
|
+
function durationGap(input, recording) {
|
|
2344
|
+
return input.durationSec && recording.durationSec ? Math.abs(input.durationSec - recording.durationSec) : 0;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/domain/audio-identification-routine.ts
|
|
2348
|
+
var RETRY_INTERVAL_MS = 60 * 60 * 1e3;
|
|
2349
|
+
var NOTIFY_INTERVAL_MS = 3e3;
|
|
2350
|
+
function fileKeyFor(item) {
|
|
2351
|
+
return `${item.size}:${Math.round(item.mtimeMs)}`;
|
|
2352
|
+
}
|
|
2353
|
+
function createIdentificationRoutine(options) {
|
|
2354
|
+
const {
|
|
2355
|
+
library,
|
|
2356
|
+
identify,
|
|
2357
|
+
methods = ["text"],
|
|
2358
|
+
source = "musicbrainz",
|
|
2359
|
+
now = Date.now,
|
|
2360
|
+
retryIntervalMs = RETRY_INTERVAL_MS,
|
|
2361
|
+
maxConsecutiveErrors = 3,
|
|
2362
|
+
onError = (error) => console.error("[identification] run failed", error)
|
|
2363
|
+
} = options;
|
|
2364
|
+
let running = null;
|
|
2365
|
+
let rerun = false;
|
|
2366
|
+
let timer = null;
|
|
2367
|
+
let disposed = false;
|
|
2368
|
+
async function runOnce() {
|
|
2369
|
+
const due = library.identificationCandidates().filter((item) => needsAudioIdentification(item.identification, fileKeyFor(item), now(), methods));
|
|
2370
|
+
const lookups = [];
|
|
2371
|
+
for (const item of due) {
|
|
2372
|
+
if (audioIdentificationDoubts(item).length) {
|
|
2373
|
+
lookups.push(item);
|
|
2374
|
+
} else {
|
|
2375
|
+
library.recordIdentification(item.id, {
|
|
2376
|
+
version: AUDIO_IDENTIFICATION_VERSION,
|
|
2377
|
+
status: "trusted",
|
|
2378
|
+
checkedAt: now(),
|
|
2379
|
+
fileKey: fileKeyFor(item)
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (!lookups.length) return { checked: 0, matched: 0 };
|
|
2384
|
+
let checked = 0;
|
|
2385
|
+
let matched = 0;
|
|
2386
|
+
let consecutiveErrors = 0;
|
|
2387
|
+
let lastNotifiedAt = now();
|
|
2388
|
+
library.setIdentificationProgress({ identifying: true, identified: 0, identifyTotal: lookups.length });
|
|
2389
|
+
for (const item of lookups) {
|
|
2390
|
+
if (disposed) break;
|
|
2391
|
+
const base = { version: AUDIO_IDENTIFICATION_VERSION, checkedAt: now(), fileKey: fileKeyFor(item), source };
|
|
2392
|
+
try {
|
|
2393
|
+
const match = await identify(item);
|
|
2394
|
+
consecutiveErrors = 0;
|
|
2395
|
+
library.recordIdentification(item.id, {
|
|
2396
|
+
...base,
|
|
2397
|
+
status: match.status,
|
|
2398
|
+
method: match.method,
|
|
2399
|
+
recordingId: match.recordingId,
|
|
2400
|
+
releaseId: match.releaseId,
|
|
2401
|
+
confidence: match.confidence,
|
|
2402
|
+
fields: match.status === "matched" ? match.fields : void 0
|
|
2403
|
+
});
|
|
2404
|
+
if (match.status === "matched") matched += 1;
|
|
2405
|
+
} catch {
|
|
2406
|
+
library.recordIdentification(item.id, { ...base, status: "error" });
|
|
2407
|
+
consecutiveErrors += 1;
|
|
2408
|
+
}
|
|
2409
|
+
checked += 1;
|
|
2410
|
+
library.setIdentificationProgress({ identifying: true, identified: checked, identifyTotal: lookups.length });
|
|
2411
|
+
if (matched && now() - lastNotifiedAt >= NOTIFY_INTERVAL_MS) {
|
|
2412
|
+
lastNotifiedAt = now();
|
|
2413
|
+
library.notifyChanged();
|
|
2414
|
+
}
|
|
2415
|
+
if (consecutiveErrors >= maxConsecutiveErrors) break;
|
|
2416
|
+
}
|
|
2417
|
+
library.setIdentificationProgress({ identifying: false, identified: 0, identifyTotal: 0 });
|
|
2418
|
+
await library.flush();
|
|
2419
|
+
if (matched) library.notifyChanged();
|
|
2420
|
+
return { checked, matched };
|
|
2421
|
+
}
|
|
2422
|
+
function run() {
|
|
2423
|
+
if (disposed) return Promise.resolve();
|
|
2424
|
+
if (running) {
|
|
2425
|
+
rerun = true;
|
|
2426
|
+
return running;
|
|
2427
|
+
}
|
|
2428
|
+
running = (async () => {
|
|
2429
|
+
do {
|
|
2430
|
+
rerun = false;
|
|
2431
|
+
await runOnce();
|
|
2432
|
+
} while (rerun && !disposed);
|
|
2433
|
+
})().catch(onError).finally(() => {
|
|
2434
|
+
running = null;
|
|
2435
|
+
});
|
|
2436
|
+
return running;
|
|
2437
|
+
}
|
|
2438
|
+
return {
|
|
2439
|
+
run,
|
|
2440
|
+
start() {
|
|
2441
|
+
void run();
|
|
2442
|
+
timer = setInterval(() => void run(), retryIntervalMs);
|
|
2443
|
+
timer.unref?.();
|
|
2444
|
+
},
|
|
2445
|
+
dispose() {
|
|
2446
|
+
disposed = true;
|
|
2447
|
+
if (timer) clearInterval(timer);
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// src/domain/media-library-model.ts
|
|
2453
|
+
var MEDIA_LIBRARY_MODEL_VERSION = 3;
|
|
2454
|
+
var MEDIA_EXTENSIONS = {
|
|
2455
|
+
audio: /* @__PURE__ */ new Set([".aac", ".aif", ".aiff", ".flac", ".m4a", ".mp3", ".oga", ".ogg", ".opus", ".wav", ".weba", ".wma"]),
|
|
2456
|
+
video: /* @__PURE__ */ new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".ogv", ".webm", ".wmv"])
|
|
2457
|
+
};
|
|
2458
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
|
2459
|
+
var FOLDER_ARTWORK_NAMES = ["cover", "folder", "front", "album", "poster", "artwork"];
|
|
2460
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "$recycle.bin", "@eadir", "system volume information", ".trash", ".trashes"]);
|
|
2461
|
+
var CONTENT_TYPES = /* @__PURE__ */ new Map([
|
|
2462
|
+
[".aac", "audio/aac"],
|
|
2463
|
+
[".aif", "audio/aiff"],
|
|
2464
|
+
[".aiff", "audio/aiff"],
|
|
2465
|
+
[".avi", "video/x-msvideo"],
|
|
2466
|
+
[".flac", "audio/flac"],
|
|
2467
|
+
[".jpeg", "image/jpeg"],
|
|
2468
|
+
[".jpg", "image/jpeg"],
|
|
2469
|
+
[".m4a", "audio/mp4"],
|
|
2470
|
+
[".m4v", "video/mp4"],
|
|
2471
|
+
[".mkv", "video/x-matroska"],
|
|
2472
|
+
[".mov", "video/quicktime"],
|
|
2473
|
+
[".mp3", "audio/mpeg"],
|
|
2474
|
+
[".mp4", "video/mp4"],
|
|
2475
|
+
[".mpeg", "video/mpeg"],
|
|
2476
|
+
[".mpg", "video/mpeg"],
|
|
2477
|
+
[".oga", "audio/ogg"],
|
|
2478
|
+
[".ogg", "audio/ogg"],
|
|
2479
|
+
[".ogv", "video/ogg"],
|
|
2480
|
+
[".opus", "audio/ogg"],
|
|
2481
|
+
[".png", "image/png"],
|
|
2482
|
+
[".wav", "audio/wav"],
|
|
2483
|
+
[".weba", "audio/webm"],
|
|
2484
|
+
[".webm", "video/webm"],
|
|
2485
|
+
[".webp", "image/webp"],
|
|
2486
|
+
[".wma", "audio/x-ms-wma"],
|
|
2487
|
+
[".wmv", "video/x-ms-wmv"]
|
|
2488
|
+
]);
|
|
2489
|
+
var RELEASE_TOKEN = /[\s._\-([]+(?:2160p|1080p|720p|576p|480p|4k|uhd|hdr10?|dv|x264|x265|h\.?264|h\.?265|hevc|avc|bluray|blu-ray|brrip|bdrip|web[\s._-]?dl|webrip|hdtv|dvdrip|remux|proper|repack|multi|vostfr|vf[fqi]?|truefrench|french|english|subfrench|aac|ac3|eac3|dts|ddp?5\.1|5\.1|7\.1|10bit)\b.*$/i;
|
|
2490
|
+
var YEAR = /(?:^|[\s._\-([])((?:19|20)\d{2})(?=$|[\s._\-)\]])/;
|
|
2491
|
+
var EPISODE_SXE = /^(.*?)[\s._-]*\bs(\d{1,2})[\s._-]*e(\d{1,3})(?:[\s._-]*e\d{1,3})*\b[\s._-]*(.*)$/i;
|
|
2492
|
+
var EPISODE_NXN = /^(.*?)[\s._-]+(\d{1,2})x(\d{2,3})\b[\s._-]*(.*)$/i;
|
|
2493
|
+
var SEASON_FOLDER = /^(?:season|saison|staffel|temporada|s)[\s._-]*(\d{1,2})$/i;
|
|
2494
|
+
var EPISODE_ONLY = /^(?:e|ep|episode|épisode)?[\s._-]*(\d{1,3})\b[\s._-]*(.*)$/i;
|
|
2495
|
+
var MOVIE_FOLDER = /^(?:movies?|films?|cin[ée]ma)$/i;
|
|
2496
|
+
var SERIES_FOLDER = /^(?:series|s[ée]ries|tv|tv[\s._-]?shows?|shows?)$/i;
|
|
2497
|
+
var LEADING_TRACK_NUMBER = /^(\d{1,3})(?:[\s._-]+|\s*-\s*)(.+)$/;
|
|
2498
|
+
function mediaFileExtension(fileName) {
|
|
2499
|
+
const name = fileName.split("/").pop() ?? fileName;
|
|
2500
|
+
const dot = name.lastIndexOf(".");
|
|
2501
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
2502
|
+
}
|
|
2503
|
+
function isMediaFileName(fileName, kind) {
|
|
2504
|
+
return MEDIA_EXTENSIONS[kind].has(mediaFileExtension(fileName));
|
|
2505
|
+
}
|
|
2506
|
+
function isImageFileName(fileName) {
|
|
2507
|
+
return IMAGE_EXTENSIONS.has(mediaFileExtension(fileName));
|
|
2508
|
+
}
|
|
2509
|
+
function isIgnoredDirectoryName(name) {
|
|
2510
|
+
return name.startsWith(".") || IGNORED_DIRECTORIES.has(name.toLowerCase());
|
|
2511
|
+
}
|
|
2512
|
+
function mediaContentType(fileName) {
|
|
2513
|
+
return CONTENT_TYPES.get(mediaFileExtension(fileName)) ?? "application/octet-stream";
|
|
2514
|
+
}
|
|
2515
|
+
function folderArtworkRank(fileName) {
|
|
2516
|
+
if (!isImageFileName(fileName)) return -1;
|
|
2517
|
+
return FOLDER_ARTWORK_NAMES.indexOf(fileStem(fileName).toLowerCase());
|
|
2518
|
+
}
|
|
2519
|
+
function cleanMediaTitle(fileStemValue) {
|
|
2520
|
+
const withoutRelease = String(fileStemValue).replace(RELEASE_TOKEN, "");
|
|
2521
|
+
return tidy2(withoutRelease.replace(/\[[^\]]*\]/g, ""));
|
|
2522
|
+
}
|
|
2523
|
+
function parseEpisodeFileName(stem) {
|
|
2524
|
+
const match = EPISODE_SXE.exec(stem) ?? EPISODE_NXN.exec(stem);
|
|
2525
|
+
if (!match) return null;
|
|
2526
|
+
return {
|
|
2527
|
+
series: nonEmpty(cleanMediaTitle(match[1])),
|
|
2528
|
+
season: positiveInteger(match[2]),
|
|
2529
|
+
episode: positiveInteger(match[3]),
|
|
2530
|
+
// The separator before the rest was consumed by the pattern: restore one so release tags ("1080p") are recognised.
|
|
2531
|
+
episodeTitle: nonEmpty(cleanMediaTitle(` ${match[4] ?? ""}`))
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
function audioFieldsFromTags({ relativePath, common = {}, format = {} }) {
|
|
2535
|
+
const stem = fileStem(relativePath);
|
|
2536
|
+
const numbered = LEADING_TRACK_NUMBER.exec(stem);
|
|
2537
|
+
const ancestors = ancestorNames(relativePath);
|
|
2538
|
+
const artists = Array.isArray(common.artists) ? common.artists.filter(Boolean) : [];
|
|
2539
|
+
const genre = Array.isArray(common.genre) ? common.genre.find(Boolean) : common.genre;
|
|
2540
|
+
const taggedArtist = nonEmpty(common.artist) ?? nonEmpty(artists.join(", ")) ?? nonEmpty(common.albumartist);
|
|
2541
|
+
const inferredFields = [
|
|
2542
|
+
!nonEmpty(common.title) && "title",
|
|
2543
|
+
!taggedArtist && "artist",
|
|
2544
|
+
!nonEmpty(common.albumartist) && !nonEmpty(common.artist) && "albumArtist",
|
|
2545
|
+
!nonEmpty(common.album) && "album",
|
|
2546
|
+
!positiveInteger(common.track?.no) && numbered && "trackNo"
|
|
2547
|
+
].filter((field) => typeof field === "string");
|
|
2548
|
+
return {
|
|
2549
|
+
title: nonEmpty(common.title) ?? cleanMediaTitle(numbered ? numbered[2] : stem),
|
|
2550
|
+
artist: taggedArtist ?? (ancestors[1] ? tidy2(ancestors[1]) : void 0),
|
|
2551
|
+
albumArtist: nonEmpty(common.albumartist) ?? nonEmpty(common.artist) ?? (ancestors[1] ? tidy2(ancestors[1]) : void 0),
|
|
2552
|
+
album: nonEmpty(common.album) ?? (ancestors[0] ? tidy2(ancestors[0]) : void 0),
|
|
2553
|
+
genre: nonEmpty(genre),
|
|
2554
|
+
trackNo: positiveInteger(common.track?.no) ?? (numbered ? positiveInteger(numbered[1]) : void 0),
|
|
2555
|
+
discNo: positiveInteger(common.disk?.no),
|
|
2556
|
+
year: positiveInteger(common.year),
|
|
2557
|
+
durationSec: durationOf(format),
|
|
2558
|
+
inferredFields: inferredFields.length ? inferredFields : void 0
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
function videoFieldsFromTags({ relativePath, common = {}, format = {} }) {
|
|
2562
|
+
const stem = fileStem(relativePath);
|
|
2563
|
+
const ancestors = ancestorNames(relativePath);
|
|
2564
|
+
const durationSec = durationOf(format);
|
|
2565
|
+
const yearMatch = YEAR.exec(stem);
|
|
2566
|
+
const taggedTitle = nonEmpty(common.title);
|
|
2567
|
+
const base = { durationSec, year: positiveInteger(common.year) ?? (yearMatch ? positiveInteger(yearMatch[1]) : void 0) };
|
|
2568
|
+
if (common.stik === 10 || nonEmpty(common.tvShow)) {
|
|
2569
|
+
const parsed2 = parseEpisodeFileName(stem);
|
|
2570
|
+
return {
|
|
2571
|
+
...base,
|
|
2572
|
+
category: "episode",
|
|
2573
|
+
series: nonEmpty(common.tvShow) ?? parsed2?.series ?? seriesFromFolders(ancestors) ?? cleanMediaTitle(stem),
|
|
2574
|
+
season: positiveInteger(common.tvSeason) ?? parsed2?.season ?? seasonFromFolders(ancestors),
|
|
2575
|
+
episode: positiveInteger(common.tvEpisode) ?? parsed2?.episode,
|
|
2576
|
+
title: taggedTitle ?? parsed2?.episodeTitle ?? cleanMediaTitle(stem)
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
const parsed = parseEpisodeFileName(stem);
|
|
2580
|
+
if (parsed?.season !== void 0 && parsed.episode !== void 0) {
|
|
2581
|
+
return {
|
|
2582
|
+
...base,
|
|
2583
|
+
category: "episode",
|
|
2584
|
+
// The folder wins over the file name. A season folder says what the series is with far
|
|
2585
|
+
// more authority than a release name: the same series arrives with several spellings, or
|
|
2586
|
+
// with none at all when the name is obfuscated, and each spelling would be its own series
|
|
2587
|
+
// in the library. What the viewer named the folder is what they mean.
|
|
2588
|
+
series: seriesFromFolders(ancestors) ?? parsed.series ?? cleanMediaTitle(stem),
|
|
2589
|
+
season: parsed.season,
|
|
2590
|
+
episode: parsed.episode,
|
|
2591
|
+
title: taggedTitle ?? parsed.episodeTitle ?? cleanMediaTitle(stem)
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
const seasonFolderIndex = ancestors.findIndex((name) => SEASON_FOLDER.test(name));
|
|
2595
|
+
if (seasonFolderIndex === 0) {
|
|
2596
|
+
const episodeMatch = EPISODE_ONLY.exec(stem);
|
|
2597
|
+
const series = ancestors[1] && !SERIES_FOLDER.test(ancestors[1]) ? tidy2(ancestors[1]) : void 0;
|
|
2598
|
+
if (series) {
|
|
2599
|
+
return {
|
|
2600
|
+
...base,
|
|
2601
|
+
category: "episode",
|
|
2602
|
+
series,
|
|
2603
|
+
season: positiveInteger(SEASON_FOLDER.exec(ancestors[0])?.[1]),
|
|
2604
|
+
episode: episodeMatch ? positiveInteger(episodeMatch[1]) : void 0,
|
|
2605
|
+
title: taggedTitle ?? nonEmpty(cleanMediaTitle(episodeMatch?.[2] ?? "")) ?? cleanMediaTitle(stem)
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
const underSeriesFolder = ancestors.findIndex((name) => SERIES_FOLDER.test(name));
|
|
2610
|
+
if (underSeriesFolder > 0) {
|
|
2611
|
+
const episodeMatch = EPISODE_ONLY.exec(stem);
|
|
2612
|
+
return {
|
|
2613
|
+
...base,
|
|
2614
|
+
category: "episode",
|
|
2615
|
+
series: tidy2(ancestors[underSeriesFolder - 1]),
|
|
2616
|
+
season: seasonFromFolders(ancestors.slice(0, underSeriesFolder)),
|
|
2617
|
+
episode: episodeMatch ? positiveInteger(episodeMatch[1]) : void 0,
|
|
2618
|
+
title: taggedTitle ?? nonEmpty(cleanMediaTitle(episodeMatch?.[2] ?? "")) ?? cleanMediaTitle(stem)
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
const movieTitle = taggedTitle ?? cleanMediaTitle(yearMatch ? stem.slice(0, yearMatch.index) : stem);
|
|
2622
|
+
const inMovieFolder = ancestors.some((name) => MOVIE_FOLDER.test(name));
|
|
2623
|
+
if (common.stik === 9 || inMovieFolder || yearMatch || durationSec !== void 0 && durationSec >= 40 * 60) {
|
|
2624
|
+
return { ...base, category: "movie", title: movieTitle || cleanMediaTitle(stem) };
|
|
2625
|
+
}
|
|
2626
|
+
return { ...base, category: "other", title: taggedTitle ?? cleanMediaTitle(stem) };
|
|
2627
|
+
}
|
|
2628
|
+
function ancestorNames(relativePath) {
|
|
2629
|
+
return relativePath.split("/").filter(Boolean).slice(0, -1).reverse();
|
|
2630
|
+
}
|
|
2631
|
+
function fileStem(path) {
|
|
2632
|
+
const name = path.split("/").pop() ?? path;
|
|
2633
|
+
const dot = name.lastIndexOf(".");
|
|
2634
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
2635
|
+
}
|
|
2636
|
+
function tidy2(value) {
|
|
2637
|
+
return String(value ?? "").replace(/[._]+/g, " ").replace(/\s*[-–]\s*$/, "").replace(/^\s*[-–]\s*/, "").replace(/\s{2,}/g, " ").trim();
|
|
2638
|
+
}
|
|
2639
|
+
function nonEmpty(value) {
|
|
2640
|
+
const text6 = typeof value === "string" ? value.trim() : "";
|
|
2641
|
+
return text6 || void 0;
|
|
2642
|
+
}
|
|
2643
|
+
function positiveInteger(value) {
|
|
2644
|
+
const number = Number(value);
|
|
2645
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
2646
|
+
}
|
|
2647
|
+
function durationOf(format) {
|
|
2648
|
+
return typeof format.duration === "number" && Number.isFinite(format.duration) && format.duration > 0 ? Math.round(format.duration) : void 0;
|
|
2649
|
+
}
|
|
2650
|
+
function seasonFromFolders(ancestors) {
|
|
2651
|
+
for (const name of ancestors) {
|
|
2652
|
+
const match = SEASON_FOLDER.exec(name);
|
|
2653
|
+
if (match) return positiveInteger(match[1]);
|
|
2654
|
+
}
|
|
2655
|
+
return void 0;
|
|
2656
|
+
}
|
|
2657
|
+
function seriesFromFolders(ancestors) {
|
|
2658
|
+
const seasonIndex = ancestors.findIndex((name) => SEASON_FOLDER.test(name));
|
|
2659
|
+
if (seasonIndex >= 0 && ancestors[seasonIndex + 1]) return tidy2(ancestors[seasonIndex + 1]);
|
|
2660
|
+
return void 0;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// src/domain/media-source.ts
|
|
2664
|
+
function normalizeMediaSourceAddress(address) {
|
|
2665
|
+
const trimmed = address.trim();
|
|
2666
|
+
if (!trimmed) throw new Error("Server address is required");
|
|
2667
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
2668
|
+
const url = new URL(withScheme);
|
|
2669
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Server address must be http or https");
|
|
2670
|
+
url.hash = "";
|
|
2671
|
+
url.search = "";
|
|
2672
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
2673
|
+
}
|
|
2674
|
+
function mediaSourceItemId(accountId, remoteId) {
|
|
2675
|
+
return `src:${accountId}:${remoteId}`;
|
|
2676
|
+
}
|
|
2677
|
+
function parseMediaSourceItemId(id) {
|
|
2678
|
+
const match = /^src:([^:]+):(.+)$/.exec(id);
|
|
2679
|
+
return match ? { accountId: match[1], remoteId: match[2] } : null;
|
|
2680
|
+
}
|
|
2681
|
+
function ticksToSeconds(ticks) {
|
|
2682
|
+
const value = Number(ticks);
|
|
2683
|
+
return Number.isFinite(value) && value > 0 ? Math.round(value / 1e7) : void 0;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// src/domain/ringtone.ts
|
|
2687
|
+
var RINGTONE_MAX_SEC = 30;
|
|
2688
|
+
var RINGTONE_FADE_SEC = 0.05;
|
|
2689
|
+
function ringtoneSegment(options) {
|
|
2690
|
+
const duration = Math.max(0, finite(options.trackDurationSec));
|
|
2691
|
+
const wanted = Math.min(Math.max(1, finite(options.lengthSec)), RINGTONE_MAX_SEC);
|
|
2692
|
+
const lengthSec = duration > 0 ? Math.min(wanted, duration) : wanted;
|
|
2693
|
+
const position = Math.min(Math.max(0, finite(options.positionSec)), duration);
|
|
2694
|
+
const startSec = duration > 0 ? Math.min(position, Math.max(0, duration - lengthSec)) : 0;
|
|
2695
|
+
return { startSec, lengthSec };
|
|
2696
|
+
}
|
|
2697
|
+
function fadeEdges(channels, sampleRate, fadeSec = RINGTONE_FADE_SEC) {
|
|
2698
|
+
const frames = channels[0]?.length ?? 0;
|
|
2699
|
+
if (!frames || sampleRate <= 0) return;
|
|
2700
|
+
const fade = Math.max(1, Math.min(Math.round(fadeSec * sampleRate), Math.floor(frames / 2)));
|
|
2701
|
+
for (const channel of channels) {
|
|
2702
|
+
for (let index = 0; index < fade; index += 1) {
|
|
2703
|
+
const gain = index / fade;
|
|
2704
|
+
channel[index] *= gain;
|
|
2705
|
+
channel[frames - 1 - index] *= gain;
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
function encodeWav(channels, sampleRate) {
|
|
2710
|
+
const channelCount = Math.max(1, channels.length);
|
|
2711
|
+
const frames = channels[0]?.length ?? 0;
|
|
2712
|
+
const bytesPerSample = 2;
|
|
2713
|
+
const blockAlign = channelCount * bytesPerSample;
|
|
2714
|
+
const dataBytes = frames * blockAlign;
|
|
2715
|
+
const buffer = new ArrayBuffer(44 + dataBytes);
|
|
2716
|
+
const view = new DataView(buffer);
|
|
2717
|
+
writeText(view, 0, "RIFF");
|
|
2718
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
2719
|
+
writeText(view, 8, "WAVE");
|
|
2720
|
+
writeText(view, 12, "fmt ");
|
|
2721
|
+
view.setUint32(16, 16, true);
|
|
2722
|
+
view.setUint16(20, 1, true);
|
|
2723
|
+
view.setUint16(22, channelCount, true);
|
|
2724
|
+
view.setUint32(24, sampleRate, true);
|
|
2725
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
2726
|
+
view.setUint16(32, blockAlign, true);
|
|
2727
|
+
view.setUint16(34, 8 * bytesPerSample, true);
|
|
2728
|
+
writeText(view, 36, "data");
|
|
2729
|
+
view.setUint32(40, dataBytes, true);
|
|
2730
|
+
let offset = 44;
|
|
2731
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
2732
|
+
for (let channel = 0; channel < channelCount; channel += 1) {
|
|
2733
|
+
const sample = Math.max(-1, Math.min(1, channels[channel]?.[frame] ?? 0));
|
|
2734
|
+
view.setInt16(offset, Math.round(sample * (sample < 0 ? 32768 : 32767)), true);
|
|
2735
|
+
offset += bytesPerSample;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return buffer;
|
|
2739
|
+
}
|
|
2740
|
+
function ringtoneFileName(title, extension = "wav") {
|
|
2741
|
+
const safe = String(title ?? "").replace(/[/\\:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2742
|
+
return `${safe || "ringtone"}.${extension}`;
|
|
2743
|
+
}
|
|
2744
|
+
function finite(value) {
|
|
2745
|
+
return Number.isFinite(value) ? value : 0;
|
|
2746
|
+
}
|
|
2747
|
+
function writeText(view, offset, text6) {
|
|
2748
|
+
for (let index = 0; index < text6.length; index += 1) view.setUint8(offset + index, text6.charCodeAt(index));
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// src/domain/shake.ts
|
|
2752
|
+
var DEFAULTS = {
|
|
2753
|
+
force: 12,
|
|
2754
|
+
reversals: 3,
|
|
2755
|
+
window: 700,
|
|
2756
|
+
rest: 600,
|
|
2757
|
+
pairWindow: 1800
|
|
2758
|
+
};
|
|
2759
|
+
function createShakeDetector(options = {}) {
|
|
2760
|
+
const settings = { ...DEFAULTS, ...options };
|
|
2761
|
+
let last = null;
|
|
2762
|
+
let direction = 0;
|
|
2763
|
+
let reversals = [];
|
|
2764
|
+
let restingUntil = 0;
|
|
2765
|
+
let firstShakeAt = 0;
|
|
2766
|
+
function read(sample) {
|
|
2767
|
+
const previous = last;
|
|
2768
|
+
last = sample;
|
|
2769
|
+
if (!previous) return null;
|
|
2770
|
+
if (sample.at < restingUntil) return null;
|
|
2771
|
+
const elapsed = sample.at - previous.at;
|
|
2772
|
+
if (elapsed <= 0) return null;
|
|
2773
|
+
const speed = (Math.abs(sample.x - previous.x) + Math.abs(sample.y - previous.y) + Math.abs(sample.z - previous.z)) / (elapsed / 1e3);
|
|
2774
|
+
if (speed < settings.force) return null;
|
|
2775
|
+
const moving = Math.sign(sample.x - previous.x + (sample.y - previous.y) + (sample.z - previous.z));
|
|
2776
|
+
if (moving !== 0 && moving !== direction) {
|
|
2777
|
+
direction = moving;
|
|
2778
|
+
reversals.push(sample.at);
|
|
2779
|
+
}
|
|
2780
|
+
reversals = reversals.filter((at) => sample.at - at <= settings.window);
|
|
2781
|
+
if (reversals.length < settings.reversals) return null;
|
|
2782
|
+
reversals = [];
|
|
2783
|
+
direction = 0;
|
|
2784
|
+
restingUntil = sample.at + settings.rest;
|
|
2785
|
+
if (firstShakeAt && sample.at - firstShakeAt <= settings.pairWindow) {
|
|
2786
|
+
firstShakeAt = 0;
|
|
2787
|
+
return "double";
|
|
2788
|
+
}
|
|
2789
|
+
firstShakeAt = sample.at;
|
|
2790
|
+
return null;
|
|
2791
|
+
}
|
|
2792
|
+
function settle(now) {
|
|
2793
|
+
if (!firstShakeAt || now - firstShakeAt <= settings.pairWindow) return null;
|
|
2794
|
+
firstShakeAt = 0;
|
|
2795
|
+
return "single";
|
|
2796
|
+
}
|
|
2797
|
+
function pendingFor(now) {
|
|
2798
|
+
return firstShakeAt ? Math.max(0, settings.pairWindow - (now - firstShakeAt)) : 0;
|
|
2799
|
+
}
|
|
2800
|
+
function reset() {
|
|
2801
|
+
last = null;
|
|
2802
|
+
direction = 0;
|
|
2803
|
+
reversals = [];
|
|
2804
|
+
restingUntil = 0;
|
|
2805
|
+
firstShakeAt = 0;
|
|
2806
|
+
}
|
|
2807
|
+
return { read, settle, pendingFor, reset };
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// src/domain/vault.ts
|
|
2811
|
+
var VAULT_ROUNDS = 31e4;
|
|
2812
|
+
async function hashPasscode(code, crypto, options = {}) {
|
|
2813
|
+
const rounds = options.rounds ?? VAULT_ROUNDS;
|
|
2814
|
+
const salt = options.salt ?? toBase64(crypto.getRandomValues(new Uint8Array(16)));
|
|
2815
|
+
const key = await crypto.subtle.importKey("raw", encode(code), "PBKDF2", false, ["deriveBits"]);
|
|
2816
|
+
const bits = await crypto.subtle.deriveBits(
|
|
2817
|
+
{ name: "PBKDF2", salt: fromBase64(salt), iterations: rounds, hash: "SHA-256" },
|
|
2818
|
+
key,
|
|
2819
|
+
256
|
|
2820
|
+
);
|
|
2821
|
+
return { salt, hash: toBase64(new Uint8Array(bits)), rounds };
|
|
2822
|
+
}
|
|
2823
|
+
async function verifyPasscode(code, record, crypto) {
|
|
2824
|
+
if (!record?.hash || !record.salt) return false;
|
|
2825
|
+
const candidate = await hashPasscode(code, crypto, { salt: record.salt, rounds: record.rounds });
|
|
2826
|
+
return equalInConstantTime(candidate.hash, record.hash);
|
|
2827
|
+
}
|
|
2828
|
+
function isUsablePasscode(code) {
|
|
2829
|
+
return /^[0-9]{4,}$/.test(code) && new Set(code).size > 1;
|
|
2830
|
+
}
|
|
2831
|
+
function hiddenIds(locked, state) {
|
|
2832
|
+
return state.open || !state.hasPasscode ? /* @__PURE__ */ new Set() : new Set(locked);
|
|
2833
|
+
}
|
|
2834
|
+
function withoutHidden(items, idOf, hidden) {
|
|
2835
|
+
return hidden.size ? items.filter((item) => !hidden.has(idOf(item))) : [...items];
|
|
2836
|
+
}
|
|
2837
|
+
function encode(text6) {
|
|
2838
|
+
const encoded = new TextEncoder().encode(text6);
|
|
2839
|
+
const bytes = new Uint8Array(new ArrayBuffer(encoded.length));
|
|
2840
|
+
bytes.set(encoded);
|
|
2841
|
+
return bytes.buffer;
|
|
2842
|
+
}
|
|
2843
|
+
function toBase64(bytes) {
|
|
2844
|
+
let binary = "";
|
|
2845
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2846
|
+
return btoa(binary);
|
|
2847
|
+
}
|
|
2848
|
+
function fromBase64(text6) {
|
|
2849
|
+
const binary = atob(text6);
|
|
2850
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
2851
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
2852
|
+
return bytes.buffer;
|
|
2853
|
+
}
|
|
2854
|
+
function equalInConstantTime(left, right) {
|
|
2855
|
+
if (left.length !== right.length) return false;
|
|
2856
|
+
let difference = 0;
|
|
2857
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
2858
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
2859
|
+
}
|
|
2860
|
+
return difference === 0;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// src/domain/device-playback.ts
|
|
2864
|
+
function createIdleDevicePlaybackState(message) {
|
|
2865
|
+
return {
|
|
2866
|
+
status: "idle",
|
|
2867
|
+
activeDeviceId: null,
|
|
2868
|
+
activeDeviceName: null,
|
|
2869
|
+
...message ? { message } : {}
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
function reduceDevicePlaybackState(state, event) {
|
|
2873
|
+
switch (event.type) {
|
|
2874
|
+
case "connect":
|
|
2875
|
+
return connectDevice(event.device, event.targetUrl, event.message);
|
|
2876
|
+
case "playing":
|
|
2877
|
+
return transitionActiveDevice(state, event.deviceId, {
|
|
2878
|
+
status: "playing",
|
|
2879
|
+
message: event.message ?? `Playing on ${state.activeDeviceName ?? "device"}.`
|
|
2880
|
+
});
|
|
2881
|
+
case "fail":
|
|
2882
|
+
return transitionActiveDevice(state, event.deviceId, {
|
|
2883
|
+
status: "failed",
|
|
2884
|
+
error: event.error,
|
|
2885
|
+
message: event.message ?? event.error
|
|
2886
|
+
});
|
|
2887
|
+
case "stop":
|
|
2888
|
+
if (event.deviceId && state.activeDeviceId !== event.deviceId) return state;
|
|
2889
|
+
return createIdleDevicePlaybackState(event.message);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
function connectDevice(device, targetUrl, message) {
|
|
2893
|
+
const activeDeviceId = device.id.trim();
|
|
2894
|
+
const activeDeviceName = device.name.trim();
|
|
2895
|
+
const cleanTargetUrl = targetUrl.trim();
|
|
2896
|
+
if (!activeDeviceId || !activeDeviceName) {
|
|
2897
|
+
return {
|
|
2898
|
+
status: "failed",
|
|
2899
|
+
activeDeviceId: activeDeviceId || null,
|
|
2900
|
+
activeDeviceName: activeDeviceName || null,
|
|
2901
|
+
error: "Playback device identity is incomplete.",
|
|
2902
|
+
message: "Playback device identity is incomplete."
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
if (!cleanTargetUrl) {
|
|
2906
|
+
return {
|
|
2907
|
+
status: "failed",
|
|
2908
|
+
activeDeviceId,
|
|
2909
|
+
activeDeviceName,
|
|
2910
|
+
error: "Playback target URL is required.",
|
|
2911
|
+
message: "Playback target URL is required."
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
return {
|
|
2915
|
+
status: "connecting",
|
|
2916
|
+
activeDeviceId,
|
|
2917
|
+
activeDeviceName,
|
|
2918
|
+
targetUrl: cleanTargetUrl,
|
|
2919
|
+
message: message ?? `Connecting to ${activeDeviceName}.`
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
function transitionActiveDevice(state, deviceId, patch) {
|
|
2923
|
+
if (state.activeDeviceId !== deviceId) return state;
|
|
2924
|
+
return {
|
|
2925
|
+
activeDeviceId: state.activeDeviceId,
|
|
2926
|
+
activeDeviceName: state.activeDeviceName,
|
|
2927
|
+
targetUrl: state.targetUrl,
|
|
2928
|
+
...patch
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// src/online/online-http.ts
|
|
2933
|
+
function createFetchOnlineJsonHttp(fetchFn = (input, init) => fetch(input, init)) {
|
|
2934
|
+
return {
|
|
2935
|
+
async getJson(url, options = {}) {
|
|
2936
|
+
const response = await fetchFn(url, { signal: options.signal, headers: { Accept: "application/json" } });
|
|
2937
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2938
|
+
return response.json();
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// src/online/media-cache.ts
|
|
2944
|
+
function planMediaCacheEviction(entries, budgetBytes, incomingBytes, protectedKeys = /* @__PURE__ */ new Set()) {
|
|
2945
|
+
let total = entries.reduce((sum, entry) => sum + entry.size, 0) + incomingBytes;
|
|
2946
|
+
if (total <= budgetBytes) return [];
|
|
2947
|
+
const evicted = [];
|
|
2948
|
+
const candidates = entries.filter((entry) => !protectedKeys.has(entry.key)).sort((left, right) => left.lastUsedAt - right.lastUsedAt);
|
|
2949
|
+
for (const entry of candidates) {
|
|
2950
|
+
evicted.push(entry.key);
|
|
2951
|
+
total -= entry.size;
|
|
2952
|
+
if (total <= budgetBytes) return evicted;
|
|
2953
|
+
}
|
|
2954
|
+
return null;
|
|
2955
|
+
}
|
|
2956
|
+
function createMediaCache(options) {
|
|
2957
|
+
const { store, budgetBytes } = options;
|
|
2958
|
+
const maxEntryBytes = Math.min(options.maxEntryBytes ?? budgetBytes / 4, budgetBytes);
|
|
2959
|
+
const fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
|
|
2960
|
+
const now = options.now ?? Date.now;
|
|
2961
|
+
const canDownload = options.canDownload ?? (() => true);
|
|
2962
|
+
const index = /* @__PURE__ */ new Map();
|
|
2963
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2964
|
+
const failures = /* @__PURE__ */ new Map();
|
|
2965
|
+
const FAILURE_RETRY_MS = 10 * 6e4;
|
|
2966
|
+
let wanted = [];
|
|
2967
|
+
let current = null;
|
|
2968
|
+
let loaded = false;
|
|
2969
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
2970
|
+
const ready = store.entries().then(
|
|
2971
|
+
(entries) => {
|
|
2972
|
+
entries.forEach((entry) => index.set(entry.key, entry));
|
|
2973
|
+
loaded = true;
|
|
2974
|
+
pump();
|
|
2975
|
+
},
|
|
2976
|
+
() => {
|
|
2977
|
+
loaded = true;
|
|
2978
|
+
}
|
|
2979
|
+
);
|
|
2980
|
+
function pump() {
|
|
2981
|
+
if (!loaded || current) return;
|
|
2982
|
+
const next = wanted.find((request2) => !index.has(request2.key) && !isFailing(request2.key));
|
|
2983
|
+
if (!next || !canDownload()) return;
|
|
2984
|
+
const controller = new AbortController();
|
|
2985
|
+
current = { key: next.key, controller };
|
|
2986
|
+
void download(next, controller.signal).finally(() => {
|
|
2987
|
+
current = null;
|
|
2988
|
+
pump();
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
function isFailing(key) {
|
|
2992
|
+
const failedAt = failures.get(key);
|
|
2993
|
+
return failedAt !== void 0 && now() - failedAt < FAILURE_RETRY_MS;
|
|
2994
|
+
}
|
|
2995
|
+
async function fetchFirst(urls, signal) {
|
|
2996
|
+
let failure = new Error("No URL to download");
|
|
2997
|
+
for (const url of urls) {
|
|
2998
|
+
try {
|
|
2999
|
+
const response = await fetchFn(url, { signal });
|
|
3000
|
+
if (response.ok) return { response, url };
|
|
3001
|
+
failure = new Error(`HTTP ${response.status}`);
|
|
3002
|
+
} catch (error) {
|
|
3003
|
+
if (signal.aborted) throw error;
|
|
3004
|
+
failure = error;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
throw failure;
|
|
3008
|
+
}
|
|
3009
|
+
async function download(request2, signal) {
|
|
3010
|
+
try {
|
|
3011
|
+
const { response, url } = await fetchFirst([request2.url, ...request2.alternateUrls ?? []], signal);
|
|
3012
|
+
const announced = Number(response.headers.get("content-length"));
|
|
3013
|
+
if (announced > maxEntryBytes) {
|
|
3014
|
+
failures.set(request2.key, now());
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const data = await response.blob();
|
|
3018
|
+
if (signal.aborted || data.size === 0 || data.size > maxEntryBytes) return;
|
|
3019
|
+
const protectedKeys = new Set(wanted.map((item) => item.key));
|
|
3020
|
+
const evicted = planMediaCacheEviction([...index.values()], budgetBytes, data.size, protectedKeys);
|
|
3021
|
+
if (!evicted) return;
|
|
3022
|
+
if (evicted.length) {
|
|
3023
|
+
await store.remove(evicted);
|
|
3024
|
+
evicted.forEach((key) => index.delete(key));
|
|
3025
|
+
}
|
|
3026
|
+
const time = now();
|
|
3027
|
+
const entry = {
|
|
3028
|
+
key: request2.key,
|
|
3029
|
+
url,
|
|
3030
|
+
size: data.size,
|
|
3031
|
+
contentType: data.type || response.headers.get("content-type") || "application/octet-stream",
|
|
3032
|
+
storedAt: time,
|
|
3033
|
+
lastUsedAt: time
|
|
3034
|
+
};
|
|
3035
|
+
await store.write(entry, data);
|
|
3036
|
+
index.set(entry.key, entry);
|
|
3037
|
+
failures.delete(request2.key);
|
|
3038
|
+
emit();
|
|
3039
|
+
} catch {
|
|
3040
|
+
if (!signal.aborted) failures.set(request2.key, now());
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
return {
|
|
3044
|
+
ready,
|
|
3045
|
+
has: (key) => index.has(key),
|
|
3046
|
+
async open(key) {
|
|
3047
|
+
const entry = index.get(key);
|
|
3048
|
+
if (!entry) return null;
|
|
3049
|
+
const data = await store.read(key).catch(() => null);
|
|
3050
|
+
if (!data) {
|
|
3051
|
+
index.delete(key);
|
|
3052
|
+
await store.remove([key]).catch(() => void 0);
|
|
3053
|
+
emit();
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
entry.lastUsedAt = now();
|
|
3057
|
+
void store.touch(key, entry.lastUsedAt).catch(() => void 0);
|
|
3058
|
+
return data;
|
|
3059
|
+
},
|
|
3060
|
+
keep(requests) {
|
|
3061
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3062
|
+
wanted = requests.filter((request2) => request2.key && request2.url && !seen.has(request2.key) && seen.add(request2.key));
|
|
3063
|
+
if (current && !wanted.some((request2) => request2.key === current?.key)) current.controller.abort();
|
|
3064
|
+
pump();
|
|
3065
|
+
},
|
|
3066
|
+
usage() {
|
|
3067
|
+
const entries = [...index.values()];
|
|
3068
|
+
return { bytes: entries.reduce((sum, entry) => sum + entry.size, 0), count: entries.length, budgetBytes };
|
|
3069
|
+
},
|
|
3070
|
+
async clear() {
|
|
3071
|
+
wanted = [];
|
|
3072
|
+
current?.controller.abort();
|
|
3073
|
+
const keys = [...index.keys()];
|
|
3074
|
+
index.clear();
|
|
3075
|
+
failures.clear();
|
|
3076
|
+
await store.remove(keys);
|
|
3077
|
+
emit();
|
|
3078
|
+
},
|
|
3079
|
+
onChange(listener) {
|
|
3080
|
+
listeners.add(listener);
|
|
3081
|
+
return () => listeners.delete(listener);
|
|
3082
|
+
}
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// src/online/jellyfin.ts
|
|
3087
|
+
var JELLYFIN_CLIENT = { name: "NGXSP", version: "1.0.0" };
|
|
3088
|
+
var AUDIO_FIELDS = "Genres,ProductionYear,RunTimeTicks,ParentIndexNumber,IndexNumber,AlbumArtist,Artists,MediaSources";
|
|
3089
|
+
var VIDEO_FIELDS = "Genres,ProductionYear,RunTimeTicks,ParentIndexNumber,IndexNumber,SeriesName,MediaSources";
|
|
3090
|
+
var PAGE_SIZE = 200;
|
|
3091
|
+
function createJellyfinConnector(deviceId = "ngxsp") {
|
|
3092
|
+
return {
|
|
3093
|
+
kind: "jellyfin",
|
|
3094
|
+
connect: (input, http) => connectJellyfin(input, http, deviceId),
|
|
3095
|
+
browse: (account, options, http) => browseJellyfin(account, options, http),
|
|
3096
|
+
lyrics: (account, item, http) => readJellyfinLyrics(account, item, http)
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
async function connectJellyfin(input, http, deviceId = "ngxsp") {
|
|
3100
|
+
const baseUrl = normalizeMediaSourceAddress(input.address);
|
|
3101
|
+
const system = await readJson(http, { url: `${baseUrl}/System/Info/Public` });
|
|
3102
|
+
const serverName = text(system?.["ServerName"]);
|
|
3103
|
+
const response = await http.send({
|
|
3104
|
+
url: `${baseUrl}/Users/AuthenticateByName`,
|
|
3105
|
+
method: "POST",
|
|
3106
|
+
headers: {
|
|
3107
|
+
"Content-Type": "application/json",
|
|
3108
|
+
Authorization: jellyfinClientHeader(deviceId)
|
|
3109
|
+
},
|
|
3110
|
+
json: { Username: input.username ?? "", Pw: input.password ?? "" }
|
|
3111
|
+
});
|
|
3112
|
+
if (response.status === 401) throw new Error("Jellyfin refused this user name or password");
|
|
3113
|
+
if (response.status >= 400) throw new Error(`Jellyfin sign-in failed (HTTP ${response.status})`);
|
|
3114
|
+
const body = await response.json();
|
|
3115
|
+
const token = text(body?.["AccessToken"]);
|
|
3116
|
+
const user = body?.["User"] ?? {};
|
|
3117
|
+
const userId = text(user["Id"]);
|
|
3118
|
+
if (!token || !userId) throw new Error("Jellyfin did not return an access token");
|
|
3119
|
+
return {
|
|
3120
|
+
id: `jellyfin-${userId.slice(0, 8)}`,
|
|
3121
|
+
kind: "jellyfin",
|
|
3122
|
+
name: input.name?.trim() || serverName || new URL(baseUrl).host,
|
|
3123
|
+
baseUrl,
|
|
3124
|
+
username: text(user["Name"]) ?? input.username,
|
|
3125
|
+
secret: token,
|
|
3126
|
+
userId
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
async function browseJellyfin(account, options, http) {
|
|
3130
|
+
const { userId } = account;
|
|
3131
|
+
if (!userId) throw new Error("Jellyfin account is not signed in");
|
|
3132
|
+
if (!options.containerId && !options.search) {
|
|
3133
|
+
const body2 = await readJson(http, request(account, `${account.baseUrl}/Users/${userId}/Views`, options.signal));
|
|
3134
|
+
const containers = itemsOf(body2).map((view) => libraryContainer(account, view)).filter((container) => !!container && (!options.kind || container.kind === options.kind || container.kind === "mixed"));
|
|
3135
|
+
return { containers, items: [] };
|
|
3136
|
+
}
|
|
3137
|
+
const parameters = new URLSearchParams({
|
|
3138
|
+
Recursive: "true",
|
|
3139
|
+
Fields: options.kind === "video" ? VIDEO_FIELDS : AUDIO_FIELDS,
|
|
3140
|
+
StartIndex: String(Math.max(0, options.startIndex ?? 0)),
|
|
3141
|
+
Limit: String(Math.min(PAGE_SIZE, Math.max(1, options.limit ?? PAGE_SIZE))),
|
|
3142
|
+
ImageTypeLimit: "1",
|
|
3143
|
+
EnableImageTypes: "Primary"
|
|
3144
|
+
});
|
|
3145
|
+
if (options.containerId) parameters.set("ParentId", options.containerId);
|
|
3146
|
+
if (options.search) parameters.set("SearchTerm", options.search);
|
|
3147
|
+
if (options.kind === "video") {
|
|
3148
|
+
parameters.set("IncludeItemTypes", "Movie,Episode,Video");
|
|
3149
|
+
parameters.set("SortBy", "SeriesSortName,ParentIndexNumber,IndexNumber,SortName");
|
|
3150
|
+
} else {
|
|
3151
|
+
parameters.set("IncludeItemTypes", "Audio");
|
|
3152
|
+
parameters.set("SortBy", "AlbumArtist,Album,ParentIndexNumber,IndexNumber,SortName");
|
|
3153
|
+
}
|
|
3154
|
+
parameters.set("SortOrder", "Ascending");
|
|
3155
|
+
const body = await readJson(http, request(account, `${account.baseUrl}/Users/${userId}/Items?${parameters}`, options.signal));
|
|
3156
|
+
const items = itemsOf(body).map((entry) => parseJellyfinItem(account, entry, options.kind ?? "audio")).filter((item) => !!item);
|
|
3157
|
+
const total = Number(body?.["TotalRecordCount"]);
|
|
3158
|
+
return { containers: [], items, ...Number.isFinite(total) ? { total } : {} };
|
|
3159
|
+
}
|
|
3160
|
+
async function readJellyfinLyrics(account, item, http) {
|
|
3161
|
+
const remoteId = parseMediaSourceItemId(item.id)?.remoteId;
|
|
3162
|
+
if (!remoteId) return null;
|
|
3163
|
+
const response = await http.send(request(account, `${account.baseUrl}/Audio/${encodeURIComponent(remoteId)}/Lyrics`));
|
|
3164
|
+
if (response.status >= 400) return null;
|
|
3165
|
+
const lines = (await response.json())?.["Lyrics"];
|
|
3166
|
+
if (!Array.isArray(lines) || !lines.length) return null;
|
|
3167
|
+
const written = lines.map((line) => {
|
|
3168
|
+
const entry = line;
|
|
3169
|
+
const words = text(entry["Text"]) ?? "";
|
|
3170
|
+
const ticks = Number(entry["Start"]);
|
|
3171
|
+
return Number.isFinite(ticks) && ticks >= 0 ? `${lrcStamp(ticks / 1e7)}${words}` : words;
|
|
3172
|
+
}).filter((line) => line.trim());
|
|
3173
|
+
return written.length ? written.join("\n") : null;
|
|
3174
|
+
}
|
|
3175
|
+
function lrcStamp(seconds) {
|
|
3176
|
+
const safe = Math.max(0, seconds);
|
|
3177
|
+
const minutes = Math.floor(safe / 60);
|
|
3178
|
+
return `[${String(minutes).padStart(2, "0")}:${(safe - minutes * 60).toFixed(2).padStart(5, "0")}]`;
|
|
3179
|
+
}
|
|
3180
|
+
function jellyfinStreamUrl(account, itemId, kind) {
|
|
3181
|
+
const path = kind === "audio" ? `Audio/${encodeURIComponent(itemId)}/stream` : `Videos/${encodeURIComponent(itemId)}/stream`;
|
|
3182
|
+
return `${account.baseUrl}/${path}?static=true&api_key=${encodeURIComponent(account.secret ?? "")}`;
|
|
3183
|
+
}
|
|
3184
|
+
function jellyfinImageUrl(account, itemId, tag, maxHeight = 480) {
|
|
3185
|
+
const parameters = new URLSearchParams({ maxHeight: String(maxHeight), quality: "90" });
|
|
3186
|
+
if (tag) parameters.set("tag", tag);
|
|
3187
|
+
return `${account.baseUrl}/Items/${encodeURIComponent(itemId)}/Images/Primary?${parameters}`;
|
|
3188
|
+
}
|
|
3189
|
+
function jellyfinClientHeader(deviceId, token) {
|
|
3190
|
+
const parts = [
|
|
3191
|
+
`Client="${JELLYFIN_CLIENT.name}"`,
|
|
3192
|
+
`Device="${JELLYFIN_CLIENT.name}"`,
|
|
3193
|
+
`DeviceId="${deviceId}"`,
|
|
3194
|
+
`Version="${JELLYFIN_CLIENT.version}"`
|
|
3195
|
+
];
|
|
3196
|
+
if (token) parts.push(`Token="${token}"`);
|
|
3197
|
+
return `MediaBrowser ${parts.join(", ")}`;
|
|
3198
|
+
}
|
|
3199
|
+
function parseJellyfinItem(account, entry, kind) {
|
|
3200
|
+
const record = entry ?? {};
|
|
3201
|
+
const remoteId = text(record["Id"]);
|
|
3202
|
+
const title = text(record["Name"]);
|
|
3203
|
+
if (!remoteId || !title) return null;
|
|
3204
|
+
const type = text(record["Type"]);
|
|
3205
|
+
const itemKind = type === "Audio" ? "audio" : type ? "video" : kind;
|
|
3206
|
+
const imageTag = text(record["ImageTags"]?.["Primary"]);
|
|
3207
|
+
const albumImageTag = text(record["AlbumPrimaryImageTag"]);
|
|
3208
|
+
const albumId = text(record["AlbumId"]);
|
|
3209
|
+
const artworkId = imageTag ? remoteId : albumImageTag && albumId ? albumId : void 0;
|
|
3210
|
+
const item = {
|
|
3211
|
+
id: mediaSourceItemId(account.id, remoteId),
|
|
3212
|
+
kind: itemKind,
|
|
3213
|
+
title,
|
|
3214
|
+
url: jellyfinStreamUrl(account, remoteId, itemKind)
|
|
3215
|
+
};
|
|
3216
|
+
const duration = ticksToSeconds(record["RunTimeTicks"]);
|
|
3217
|
+
if (duration) item.durationSec = duration;
|
|
3218
|
+
if (artworkId) item.artworkUrl = jellyfinImageUrl(account, artworkId, imageTag ?? albumImageTag);
|
|
3219
|
+
const year = Number(record["ProductionYear"]);
|
|
3220
|
+
if (Number.isInteger(year) && year > 0) item.year = year;
|
|
3221
|
+
const genre = allTexts(record["Genres"])[0];
|
|
3222
|
+
if (genre) item.genre = genre;
|
|
3223
|
+
const container = text(record["Container"]);
|
|
3224
|
+
if (container) item.container = container;
|
|
3225
|
+
if (itemKind === "audio") {
|
|
3226
|
+
const artists = allTexts(record["Artists"]);
|
|
3227
|
+
const albumArtist = text(record["AlbumArtist"]);
|
|
3228
|
+
if (artists.length) item.artist = artists.join(", ");
|
|
3229
|
+
else if (albumArtist) item.artist = albumArtist;
|
|
3230
|
+
if (albumArtist) item.albumArtist = albumArtist;
|
|
3231
|
+
const album = text(record["Album"]);
|
|
3232
|
+
if (album) item.album = album;
|
|
3233
|
+
const trackNo = positiveInteger2(record["IndexNumber"]);
|
|
3234
|
+
if (trackNo) item.trackNo = trackNo;
|
|
3235
|
+
const discNo = positiveInteger2(record["ParentIndexNumber"]);
|
|
3236
|
+
if (discNo) item.discNo = discNo;
|
|
3237
|
+
return item;
|
|
3238
|
+
}
|
|
3239
|
+
const series = text(record["SeriesName"]);
|
|
3240
|
+
if (series) {
|
|
3241
|
+
item.series = series;
|
|
3242
|
+
const season = positiveInteger2(record["ParentIndexNumber"]);
|
|
3243
|
+
if (season) item.season = season;
|
|
3244
|
+
const episode = positiveInteger2(record["IndexNumber"]);
|
|
3245
|
+
if (episode) item.episode = episode;
|
|
3246
|
+
}
|
|
3247
|
+
return item;
|
|
3248
|
+
}
|
|
3249
|
+
function libraryContainer(account, view) {
|
|
3250
|
+
const record = view ?? {};
|
|
3251
|
+
const id = text(record["Id"]);
|
|
3252
|
+
const title = text(record["Name"]);
|
|
3253
|
+
if (!id || !title) return null;
|
|
3254
|
+
const collection = text(record["CollectionType"]);
|
|
3255
|
+
const kind = collection === "music" ? "audio" : collection === "movies" || collection === "tvshows" || collection === "homevideos" || collection === "musicvideos" ? "video" : collection ? null : "mixed";
|
|
3256
|
+
if (!kind) return null;
|
|
3257
|
+
const imageTag = text(record["ImageTags"]?.["Primary"]);
|
|
3258
|
+
return {
|
|
3259
|
+
id,
|
|
3260
|
+
title,
|
|
3261
|
+
kind,
|
|
3262
|
+
...collection ? { subtitle: collection } : {},
|
|
3263
|
+
...imageTag ? { artworkUrl: jellyfinImageUrl(account, id, imageTag) } : {}
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
function request(account, url, signal) {
|
|
3267
|
+
return {
|
|
3268
|
+
url,
|
|
3269
|
+
headers: { Authorization: jellyfinClientHeader(account.id, account.secret), Accept: "application/json" },
|
|
3270
|
+
...signal ? { signal } : {}
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
async function readJson(http, options) {
|
|
3274
|
+
const response = await http.send(options);
|
|
3275
|
+
if (response.status === 401 || response.status === 403) throw new Error("Jellyfin refused the connection; sign in again");
|
|
3276
|
+
if (response.status >= 400) throw new Error(`Jellyfin request failed (HTTP ${response.status})`);
|
|
3277
|
+
return response.json();
|
|
3278
|
+
}
|
|
3279
|
+
function itemsOf(body) {
|
|
3280
|
+
const items = body?.["Items"];
|
|
3281
|
+
return Array.isArray(items) ? items : [];
|
|
3282
|
+
}
|
|
3283
|
+
function text(value) {
|
|
3284
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
3285
|
+
}
|
|
3286
|
+
function allTexts(value) {
|
|
3287
|
+
return (Array.isArray(value) ? value : [value]).flatMap((entry) => {
|
|
3288
|
+
const item = text(entry);
|
|
3289
|
+
return item ? [item] : [];
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
function positiveInteger2(value) {
|
|
3293
|
+
const number = Number(value);
|
|
3294
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
// src/online/xml-text.ts
|
|
3298
|
+
function xmlTagContent(xml, name) {
|
|
3299
|
+
const match = new RegExp(`<(?:[\\w.-]+:)?${name}\\b[^>]*?(?:/>|>([\\s\\S]*?)</(?:[\\w.-]+:)?${name}>)`, "i").exec(xml);
|
|
3300
|
+
return match ? match[1] ?? "" : void 0;
|
|
3301
|
+
}
|
|
3302
|
+
function xmlTagText(xml, name) {
|
|
3303
|
+
return xmlText(xmlTagContent(xml, name));
|
|
3304
|
+
}
|
|
3305
|
+
function xmlElements(xml, name) {
|
|
3306
|
+
const pattern = new RegExp(`<(?:[\\w.-]+:)?${name}\\b((?:[^>"']|"[^"]*"|'[^']*')*?)(?:/>|>([\\s\\S]*?)</(?:[\\w.-]+:)?${name}>)`, "gi");
|
|
3307
|
+
return [...xml.matchAll(pattern)].map((match) => ({ attributes: xmlAttributes(match[1]), content: match[2] }));
|
|
3308
|
+
}
|
|
3309
|
+
function xmlText(content) {
|
|
3310
|
+
const value = content === void 0 ? void 0 : decodeXmlEntities(content.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")).trim();
|
|
3311
|
+
return value || void 0;
|
|
3312
|
+
}
|
|
3313
|
+
function decodeXmlEntities(value) {
|
|
3314
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16))).replace(/&/g, "&");
|
|
3315
|
+
}
|
|
3316
|
+
function escapeXmlText(value) {
|
|
3317
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3318
|
+
}
|
|
3319
|
+
function xmlAttributes(source) {
|
|
3320
|
+
const attributes = {};
|
|
3321
|
+
for (const match of source.matchAll(/([\w.:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
|
3322
|
+
attributes[match[1].replace(/^[\w.-]+:/, "")] = decodeXmlEntities(match[3] ?? match[4] ?? "");
|
|
3323
|
+
}
|
|
3324
|
+
return attributes;
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// src/online/upnp.ts
|
|
3328
|
+
var UpnpActionError = class extends Error {
|
|
3329
|
+
constructor(action, code, status) {
|
|
3330
|
+
super(`UPnP ${action} failed (${code ? `UPnP error ${code}` : `HTTP ${status}`})`);
|
|
3331
|
+
this.action = action;
|
|
3332
|
+
this.code = code;
|
|
3333
|
+
this.status = status;
|
|
3334
|
+
this.name = "UpnpActionError";
|
|
3335
|
+
}
|
|
3336
|
+
action;
|
|
3337
|
+
code;
|
|
3338
|
+
status;
|
|
3339
|
+
};
|
|
3340
|
+
function parseUpnpDeviceDescription(xml, descriptionUrl) {
|
|
3341
|
+
const base = xmlTagText(xml, "URLBase") ?? descriptionUrl;
|
|
3342
|
+
const services = xmlElements(xml, "service").flatMap((element) => {
|
|
3343
|
+
const content = element.content ?? "";
|
|
3344
|
+
const serviceType = xmlTagText(content, "serviceType");
|
|
3345
|
+
const controlUrl = resolveUpnpUrl(xmlTagText(content, "controlURL"), base);
|
|
3346
|
+
return serviceType && controlUrl ? [{ serviceType, controlUrl }] : [];
|
|
3347
|
+
});
|
|
3348
|
+
const icon = xmlElements(xml, "icon").map((element) => ({
|
|
3349
|
+
url: resolveUpnpUrl(xmlTagText(element.content ?? "", "url"), base),
|
|
3350
|
+
width: Number(xmlTagText(element.content ?? "", "width")) || 0,
|
|
3351
|
+
type: xmlTagText(element.content ?? "", "mimetype") ?? ""
|
|
3352
|
+
})).filter((candidate) => candidate.url && /png|jpe?g/i.test(candidate.type)).sort((left, right) => right.width - left.width)[0];
|
|
3353
|
+
const udn = xmlTagText(xml, "UDN");
|
|
3354
|
+
const deviceType = xmlTagText(xml, "deviceType");
|
|
3355
|
+
const manufacturer = xmlTagText(xml, "manufacturer");
|
|
3356
|
+
const modelName = xmlTagText(xml, "modelName");
|
|
3357
|
+
const contentDirectory2 = findUpnpService(services, "ContentDirectory");
|
|
3358
|
+
return {
|
|
3359
|
+
friendlyName: xmlTagText(xml, "friendlyName") ?? modelName ?? new URL(descriptionUrl).host,
|
|
3360
|
+
...udn ? { udn } : {},
|
|
3361
|
+
...deviceType ? { deviceType } : {},
|
|
3362
|
+
...manufacturer ? { manufacturer } : {},
|
|
3363
|
+
...modelName ? { modelName } : {},
|
|
3364
|
+
...icon?.url ? { iconUrl: icon.url } : {},
|
|
3365
|
+
services,
|
|
3366
|
+
...contentDirectory2 ? { contentDirectory: contentDirectory2 } : {}
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
function findUpnpService(services, name) {
|
|
3370
|
+
return services.find((service) => service.serviceType.startsWith(`urn:schemas-upnp-org:service:${name}:`));
|
|
3371
|
+
}
|
|
3372
|
+
async function upnpAction(http, service, action, soapArguments, signal) {
|
|
3373
|
+
const body = `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${service.serviceType}">` + Object.entries(soapArguments).map(([name, value]) => `<${name}>${escapeXmlText(value)}</${name}>`).join("") + `</u:${action}></s:Body></s:Envelope>`;
|
|
3374
|
+
const response = await http.send({
|
|
3375
|
+
url: service.controlUrl,
|
|
3376
|
+
method: "POST",
|
|
3377
|
+
headers: { "Content-Type": 'text/xml; charset="utf-8"', SOAPACTION: `"${service.serviceType}#${action}"` },
|
|
3378
|
+
body,
|
|
3379
|
+
...signal ? { signal } : {}
|
|
3380
|
+
});
|
|
3381
|
+
const text6 = await response.text();
|
|
3382
|
+
if (response.status >= 400) throw new UpnpActionError(action, xmlTagText(text6, "errorCode"), response.status);
|
|
3383
|
+
return text6;
|
|
3384
|
+
}
|
|
3385
|
+
function resolveUpnpUrl(value, base) {
|
|
3386
|
+
if (!value) return void 0;
|
|
3387
|
+
try {
|
|
3388
|
+
const url = new URL(value, base);
|
|
3389
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : void 0;
|
|
3390
|
+
} catch {
|
|
3391
|
+
return void 0;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
// src/online/dlna.ts
|
|
3396
|
+
var DLNA_MEDIA_SERVER_SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaServer:1";
|
|
3397
|
+
var DESCRIPTION_PATHS = ["/rootDesc.xml", "/description.xml", "/DeviceDescription.xml"];
|
|
3398
|
+
var LEADING_TRACK_NUMBER2 = /^(\d{1,3})(?:[\s._-]+|\s*-\s*)(.+)$/;
|
|
3399
|
+
var PAGE_SIZE2 = 200;
|
|
3400
|
+
var MAX_ENTRIES = 5e3;
|
|
3401
|
+
function createDlnaConnector() {
|
|
3402
|
+
return {
|
|
3403
|
+
kind: "dlna",
|
|
3404
|
+
connect: (input, http) => connectDlna(input, http),
|
|
3405
|
+
browse: (account, options, http) => browseDlna(account, options, http)
|
|
3406
|
+
};
|
|
3407
|
+
}
|
|
3408
|
+
async function connectDlna(input, http) {
|
|
3409
|
+
const address = normalizeMediaSourceAddress(input.address);
|
|
3410
|
+
const candidates = new URL(address).pathname === "/" ? DESCRIPTION_PATHS.map((path) => `${address}${path}`) : [address];
|
|
3411
|
+
for (const descriptionUrl of candidates) {
|
|
3412
|
+
const response = await http.send({ url: descriptionUrl });
|
|
3413
|
+
if (response.status >= 400) continue;
|
|
3414
|
+
const description = parseUpnpDeviceDescription(await response.text(), descriptionUrl);
|
|
3415
|
+
if (!description.contentDirectory) throw new Error("This device is not a media server");
|
|
3416
|
+
contentDirectories.set(descriptionUrl, Promise.resolve(description.contentDirectory));
|
|
3417
|
+
return {
|
|
3418
|
+
id: `dlna-${shortHash(description.udn ?? descriptionUrl)}`,
|
|
3419
|
+
kind: "dlna",
|
|
3420
|
+
name: input.name?.trim() || description.friendlyName,
|
|
3421
|
+
baseUrl: descriptionUrl
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
throw new Error("No media server description at this address");
|
|
3425
|
+
}
|
|
3426
|
+
async function browseDlna(account, options, http) {
|
|
3427
|
+
const service = await contentDirectory(account, http, options.signal);
|
|
3428
|
+
const kind = options.kind ?? "audio";
|
|
3429
|
+
const limit = Math.min(options.limit ?? MAX_ENTRIES, MAX_ENTRIES);
|
|
3430
|
+
const objects = [];
|
|
3431
|
+
let start = options.startIndex ?? 0;
|
|
3432
|
+
let total;
|
|
3433
|
+
try {
|
|
3434
|
+
while (objects.length < limit) {
|
|
3435
|
+
const count = Math.min(PAGE_SIZE2, limit - objects.length);
|
|
3436
|
+
const soapArguments = options.search ? {
|
|
3437
|
+
ContainerID: "0",
|
|
3438
|
+
SearchCriteria: `upnp:class derivedfrom "object.item.${kind}Item" and dc:title contains "${options.search.replace(/["\\]/g, "\\$&")}"`,
|
|
3439
|
+
Filter: "*",
|
|
3440
|
+
StartingIndex: String(start),
|
|
3441
|
+
RequestedCount: String(count),
|
|
3442
|
+
SortCriteria: ""
|
|
3443
|
+
} : {
|
|
3444
|
+
ObjectID: options.containerId ?? "0",
|
|
3445
|
+
BrowseFlag: "BrowseDirectChildren",
|
|
3446
|
+
Filter: "*",
|
|
3447
|
+
StartingIndex: String(start),
|
|
3448
|
+
RequestedCount: String(count),
|
|
3449
|
+
SortCriteria: ""
|
|
3450
|
+
};
|
|
3451
|
+
const answer = await soap(http, service, options.search ? "Search" : "Browse", soapArguments, options.signal, Boolean(options.search));
|
|
3452
|
+
if (!answer) return { containers: [], items: [] };
|
|
3453
|
+
const page = parseDidlLite(answer.result, service.controlUrl);
|
|
3454
|
+
objects.push(...page);
|
|
3455
|
+
total = answer.totalMatches;
|
|
3456
|
+
start += answer.numberReturned || page.length;
|
|
3457
|
+
if (!page.length || total !== void 0 && start >= total) break;
|
|
3458
|
+
}
|
|
3459
|
+
} catch (error) {
|
|
3460
|
+
contentDirectories.delete(account.baseUrl);
|
|
3461
|
+
throw error;
|
|
3462
|
+
}
|
|
3463
|
+
const containers = options.search ? [] : objects.filter((object) => object.container && showsContainer(object, kind)).map(containerFrom);
|
|
3464
|
+
const items = objects.filter((object) => !object.container).flatMap((object) => itemFrom(account, object, kind) ?? []);
|
|
3465
|
+
return { containers, items, total: items.length };
|
|
3466
|
+
}
|
|
3467
|
+
function parseDidlLite(xml, baseUrl) {
|
|
3468
|
+
const document2 = /<(?:[\w.-]+:)?DIDL-Lite\b/i.test(xml) ? xml : "";
|
|
3469
|
+
const read = (element, container) => {
|
|
3470
|
+
const content = element.content ?? "";
|
|
3471
|
+
const id = element.attributes["id"];
|
|
3472
|
+
if (!id) return null;
|
|
3473
|
+
const resources = xmlElements(content, "res").flatMap((resource) => {
|
|
3474
|
+
const url = resolveUpnpUrl(xmlText(resource.content), baseUrl);
|
|
3475
|
+
if (!url) return [];
|
|
3476
|
+
const mimeType = resource.attributes["protocolInfo"]?.split(":")[2];
|
|
3477
|
+
const durationSec = parseDuration(resource.attributes["duration"]);
|
|
3478
|
+
const size = Number(resource.attributes["size"]);
|
|
3479
|
+
return [{
|
|
3480
|
+
url,
|
|
3481
|
+
...mimeType && mimeType !== "*" ? { mimeType: mimeType.toLowerCase() } : {},
|
|
3482
|
+
...durationSec ? { durationSec } : {},
|
|
3483
|
+
...Number.isFinite(size) && size > 0 ? { size } : {}
|
|
3484
|
+
}];
|
|
3485
|
+
});
|
|
3486
|
+
const childCount = Number(element.attributes["childCount"]);
|
|
3487
|
+
const object = {
|
|
3488
|
+
id,
|
|
3489
|
+
container,
|
|
3490
|
+
title: xmlTagText(content, "title") ?? id,
|
|
3491
|
+
upnpClass: xmlTagText(content, "class") ?? (container ? "object.container" : "object.item"),
|
|
3492
|
+
artists: xmlElements(content, "artist").flatMap((artist) => {
|
|
3493
|
+
const name = xmlText(artist.content);
|
|
3494
|
+
return name ? [{ name, ...artist.attributes["role"] ? { role: artist.attributes["role"] } : {} }] : [];
|
|
3495
|
+
}),
|
|
3496
|
+
resources
|
|
3497
|
+
};
|
|
3498
|
+
const optional = {
|
|
3499
|
+
parentId: element.attributes["parentID"],
|
|
3500
|
+
childCount: Number.isFinite(childCount) && element.attributes["childCount"] !== void 0 ? childCount : void 0,
|
|
3501
|
+
creator: xmlTagText(content, "creator"),
|
|
3502
|
+
album: xmlTagText(content, "album"),
|
|
3503
|
+
genre: xmlTagText(content, "genre"),
|
|
3504
|
+
date: xmlTagText(content, "date"),
|
|
3505
|
+
trackNo: positive(xmlTagText(content, "originalTrackNumber")),
|
|
3506
|
+
artworkUrl: resolveUpnpUrl(xmlTagText(content, "albumArtURI"), baseUrl),
|
|
3507
|
+
seriesTitle: xmlTagText(content, "seriesTitle"),
|
|
3508
|
+
season: positive(xmlTagText(content, "episodeSeason")),
|
|
3509
|
+
episode: positive(xmlTagText(content, "episodeNumber"))
|
|
3510
|
+
};
|
|
3511
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
3512
|
+
if (value !== void 0) object[key] = value;
|
|
3513
|
+
}
|
|
3514
|
+
return object;
|
|
3515
|
+
};
|
|
3516
|
+
return [
|
|
3517
|
+
...xmlElements(document2, "container").flatMap((element) => read(element, true) ?? []),
|
|
3518
|
+
...xmlElements(document2, "item").flatMap((element) => read(element, false) ?? [])
|
|
3519
|
+
];
|
|
3520
|
+
}
|
|
3521
|
+
var contentDirectories = /* @__PURE__ */ new Map();
|
|
3522
|
+
function contentDirectory(account, http, signal) {
|
|
3523
|
+
let pending = contentDirectories.get(account.baseUrl);
|
|
3524
|
+
if (!pending) {
|
|
3525
|
+
pending = (async () => {
|
|
3526
|
+
const response = await http.send({ url: account.baseUrl, ...signal ? { signal } : {} });
|
|
3527
|
+
if (response.status >= 400) throw new Error("The media server is not reachable at its last address");
|
|
3528
|
+
const service = parseUpnpDeviceDescription(await response.text(), account.baseUrl).contentDirectory;
|
|
3529
|
+
if (!service) throw new Error("This device is not a media server");
|
|
3530
|
+
return service;
|
|
3531
|
+
})();
|
|
3532
|
+
contentDirectories.set(account.baseUrl, pending);
|
|
3533
|
+
pending.catch(() => contentDirectories.delete(account.baseUrl));
|
|
3534
|
+
}
|
|
3535
|
+
return pending;
|
|
3536
|
+
}
|
|
3537
|
+
async function soap(http, service, action, soapArguments, signal, optional) {
|
|
3538
|
+
let text6;
|
|
3539
|
+
try {
|
|
3540
|
+
text6 = await upnpAction(http, service, action, soapArguments, signal);
|
|
3541
|
+
} catch (error) {
|
|
3542
|
+
if (!(error instanceof UpnpActionError)) throw error;
|
|
3543
|
+
if (optional && (error.code === "401" || error.code === "708" || error.code === "710")) return null;
|
|
3544
|
+
if (error.code === "701" || error.code === "710") throw new Error("This folder is no longer on the media server");
|
|
3545
|
+
throw new Error(`Media server request failed (${error.code ? `UPnP error ${error.code}` : `HTTP ${error.status}`})`);
|
|
3546
|
+
}
|
|
3547
|
+
const totalMatches = Number(xmlTagText(text6, "TotalMatches"));
|
|
3548
|
+
return {
|
|
3549
|
+
result: xmlText(xmlTagContent(text6, "Result")) ?? "",
|
|
3550
|
+
numberReturned: Number(xmlTagText(text6, "NumberReturned")) || 0,
|
|
3551
|
+
...Number.isFinite(totalMatches) && xmlTagText(text6, "TotalMatches") !== void 0 ? { totalMatches } : {}
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
function showsContainer(object, kind) {
|
|
3555
|
+
const upnpClass = object.upnpClass.toLowerCase();
|
|
3556
|
+
if (upnpClass.startsWith("object.container.album.photoalbum")) return false;
|
|
3557
|
+
if (kind === "video") return !/^object\.container\.(album\.musicalbum|person\.musicartist|genre\.musicgenre)/.test(upnpClass);
|
|
3558
|
+
return !/^object\.container\.(genre\.moviegenre|storagefolder\.video)/.test(upnpClass);
|
|
3559
|
+
}
|
|
3560
|
+
function containerFrom(object) {
|
|
3561
|
+
const upnpClass = object.upnpClass.toLowerCase();
|
|
3562
|
+
const kind = /music|audio/.test(upnpClass) ? "audio" : /video|movie/.test(upnpClass) ? "video" : "mixed";
|
|
3563
|
+
const artist = object.artists.find((entry) => !entry.role)?.name ?? object.creator;
|
|
3564
|
+
return {
|
|
3565
|
+
id: object.id,
|
|
3566
|
+
title: object.title,
|
|
3567
|
+
kind,
|
|
3568
|
+
...artist && upnpClass.startsWith("object.container.album") ? { subtitle: artist } : {},
|
|
3569
|
+
...object.artworkUrl ? { artworkUrl: object.artworkUrl } : {},
|
|
3570
|
+
...object.childCount !== void 0 ? { childCount: object.childCount } : {}
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
function itemFrom(account, object, kind) {
|
|
3574
|
+
const upnpClass = object.upnpClass.toLowerCase();
|
|
3575
|
+
const classKind = upnpClass.startsWith("object.item.audioitem") ? "audio" : upnpClass.startsWith("object.item.videoitem") ? "video" : void 0;
|
|
3576
|
+
if (classKind && classKind !== kind) return null;
|
|
3577
|
+
const resource = object.resources.find((entry) => entry.mimeType?.startsWith(`${kind}/`)) ?? (classKind ? object.resources.find((entry) => !entry.mimeType?.startsWith("image/")) : void 0);
|
|
3578
|
+
if (!resource) return null;
|
|
3579
|
+
const item = {
|
|
3580
|
+
id: mediaSourceItemId(account.id, object.id),
|
|
3581
|
+
kind,
|
|
3582
|
+
title: object.title,
|
|
3583
|
+
url: resource.url
|
|
3584
|
+
};
|
|
3585
|
+
if (resource.durationSec) item.durationSec = resource.durationSec;
|
|
3586
|
+
if (object.artworkUrl) item.artworkUrl = object.artworkUrl;
|
|
3587
|
+
if (object.genre) item.genre = object.genre;
|
|
3588
|
+
const year = object.date && !object.date.includes("T") ? positive(object.date.slice(0, 4)) : void 0;
|
|
3589
|
+
if (year) item.year = year;
|
|
3590
|
+
if (kind === "audio") {
|
|
3591
|
+
const albumArtist = object.artists.find((entry) => /^albumartist$/i.test(entry.role ?? ""))?.name;
|
|
3592
|
+
const artist = object.artists.find((entry) => !entry.role || /^performer$/i.test(entry.role))?.name ?? object.creator ?? albumArtist;
|
|
3593
|
+
if (artist) item.artist = artist;
|
|
3594
|
+
if (albumArtist) item.albumArtist = albumArtist;
|
|
3595
|
+
if (object.album) item.album = object.album;
|
|
3596
|
+
if (object.trackNo) item.trackNo = object.trackNo;
|
|
3597
|
+
const numbered = LEADING_TRACK_NUMBER2.exec(object.title);
|
|
3598
|
+
if (numbered && !object.trackNo && !object.album && !artist) {
|
|
3599
|
+
item.trackNo = Number(numbered[1]);
|
|
3600
|
+
item.title = cleanMediaTitle(numbered[2]) || object.title;
|
|
3601
|
+
}
|
|
3602
|
+
return item;
|
|
3603
|
+
}
|
|
3604
|
+
if (object.seriesTitle || object.season && object.episode) {
|
|
3605
|
+
if (object.seriesTitle) item.series = object.seriesTitle;
|
|
3606
|
+
if (object.season) item.season = object.season;
|
|
3607
|
+
if (object.episode) item.episode = object.episode;
|
|
3608
|
+
return item;
|
|
3609
|
+
}
|
|
3610
|
+
const parsed = parseEpisodeFileName(object.title);
|
|
3611
|
+
if (parsed?.season !== void 0 && parsed.episode !== void 0) {
|
|
3612
|
+
if (parsed.series) item.series = parsed.series;
|
|
3613
|
+
item.season = parsed.season;
|
|
3614
|
+
item.episode = parsed.episode;
|
|
3615
|
+
item.title = parsed.episodeTitle ?? parsed.series ?? object.title;
|
|
3616
|
+
} else if (!/\s/.test(object.title) && /[._]/.test(object.title)) {
|
|
3617
|
+
item.title = cleanMediaTitle(object.title) || object.title;
|
|
3618
|
+
}
|
|
3619
|
+
return item;
|
|
3620
|
+
}
|
|
3621
|
+
function parseDuration(value) {
|
|
3622
|
+
const match = /^(\d+):(\d{1,2}):(\d{1,2})(?:\.(\d+)(?:\/(\d+))?)?$/.exec(value?.trim() ?? "");
|
|
3623
|
+
if (!match) return void 0;
|
|
3624
|
+
const fraction = match[4] ? match[5] ? Number(match[4]) / Number(match[5]) : Number(`0.${match[4]}`) : 0;
|
|
3625
|
+
const seconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + fraction;
|
|
3626
|
+
return seconds > 0 ? Math.round(seconds) : void 0;
|
|
3627
|
+
}
|
|
3628
|
+
function positive(value) {
|
|
3629
|
+
const number = Number(value);
|
|
3630
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
3631
|
+
}
|
|
3632
|
+
function shortHash(value) {
|
|
3633
|
+
let hash = 0;
|
|
3634
|
+
for (let index = 0; index < value.length; index += 1) hash = Math.imul(31, hash) + value.charCodeAt(index) | 0;
|
|
3635
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
// src/online/dlna-renderer.ts
|
|
3639
|
+
var DLNA_MEDIA_RENDERER_SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1";
|
|
3640
|
+
async function readDlnaRenderer(descriptionUrl, http) {
|
|
3641
|
+
const response = await http.send({ url: descriptionUrl });
|
|
3642
|
+
if (response.status >= 400) throw new Error("The renderer is not reachable");
|
|
3643
|
+
const description = parseUpnpDeviceDescription(await response.text(), descriptionUrl);
|
|
3644
|
+
const avTransport = findUpnpService(description.services, "AVTransport");
|
|
3645
|
+
if (!avTransport) throw new Error("This device cannot play media sent to it");
|
|
3646
|
+
const renderingControl = findUpnpService(description.services, "RenderingControl");
|
|
3647
|
+
return {
|
|
3648
|
+
name: description.friendlyName,
|
|
3649
|
+
...description.udn ? { udn: description.udn } : {},
|
|
3650
|
+
...description.modelName ? { modelName: description.modelName } : {},
|
|
3651
|
+
avTransport,
|
|
3652
|
+
...renderingControl ? { renderingControl } : {}
|
|
3653
|
+
};
|
|
3654
|
+
}
|
|
3655
|
+
async function createDlnaRendererSession(descriptionUrl, http) {
|
|
3656
|
+
const renderer = await readDlnaRenderer(descriptionUrl, http);
|
|
3657
|
+
const transport = (action, soapArguments = {}) => upnpAction(http, renderer.avTransport, action, { InstanceID: "0", ...soapArguments });
|
|
3658
|
+
return {
|
|
3659
|
+
kind: "dlna",
|
|
3660
|
+
async load(media) {
|
|
3661
|
+
const soapArguments = { CurrentURI: media.url, CurrentURIMetaData: didlMetadata(media) };
|
|
3662
|
+
try {
|
|
3663
|
+
await transport("SetAVTransportURI", soapArguments);
|
|
3664
|
+
} catch (error) {
|
|
3665
|
+
if (!(error instanceof UpnpActionError)) throw error;
|
|
3666
|
+
await transport("Stop").catch(() => void 0);
|
|
3667
|
+
await transport("SetAVTransportURI", soapArguments).catch((retryError) => {
|
|
3668
|
+
if (retryError instanceof UpnpActionError && retryError.code === "714") {
|
|
3669
|
+
throw new RemotePlaybackError("load-failed", "The renderer does not read this format");
|
|
3670
|
+
}
|
|
3671
|
+
throw retryError;
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
await transport("Play", { Speed: "1" });
|
|
3675
|
+
if (media.startSec && media.startSec > 1 && !media.live) {
|
|
3676
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
3677
|
+
const state = xmlTagText(await transport("GetTransportInfo").catch(() => ""), "CurrentTransportState");
|
|
3678
|
+
if (state === "PAUSED_PLAYBACK") break;
|
|
3679
|
+
if (state === "PLAYING" && (parseRemoteClock(xmlTagText(await transport("GetPositionInfo").catch(() => ""), "RelTime")) ?? 0) > 0) break;
|
|
3680
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3681
|
+
}
|
|
3682
|
+
await transport("Seek", { Unit: "REL_TIME", Target: formatRemoteClock(media.startSec) }).catch(() => void 0);
|
|
3683
|
+
}
|
|
3684
|
+
},
|
|
3685
|
+
play: async () => void await transport("Play", { Speed: "1" }),
|
|
3686
|
+
pause: async () => void await transport("Pause"),
|
|
3687
|
+
seek: async (positionSec) => void await transport("Seek", { Unit: "REL_TIME", Target: formatRemoteClock(positionSec) }),
|
|
3688
|
+
stop: async () => void await transport("Stop"),
|
|
3689
|
+
async setVolume(level) {
|
|
3690
|
+
if (!renderer.renderingControl) return;
|
|
3691
|
+
const volume = String(Math.round(Math.min(Math.max(level, 0), 1) * 100));
|
|
3692
|
+
await upnpAction(http, renderer.renderingControl, "SetVolume", { InstanceID: "0", Channel: "Master", DesiredVolume: volume });
|
|
3693
|
+
},
|
|
3694
|
+
async status() {
|
|
3695
|
+
const [transportInfo, positionInfo, volumeInfo] = await Promise.all([
|
|
3696
|
+
transport("GetTransportInfo"),
|
|
3697
|
+
transport("GetPositionInfo").catch(() => ""),
|
|
3698
|
+
renderer.renderingControl ? upnpAction(http, renderer.renderingControl, "GetVolume", { InstanceID: "0", Channel: "Master" }).catch(() => "") : Promise.resolve("")
|
|
3699
|
+
]);
|
|
3700
|
+
const positionSec = parseRemoteClock(xmlTagText(positionInfo, "RelTime"));
|
|
3701
|
+
const durationSec = parseRemoteClock(xmlTagText(positionInfo, "TrackDuration"));
|
|
3702
|
+
const volume = Number(xmlTagText(volumeInfo, "CurrentVolume"));
|
|
3703
|
+
const transportStatus = xmlTagText(transportInfo, "CurrentTransportStatus");
|
|
3704
|
+
return {
|
|
3705
|
+
state: playerState(xmlTagText(transportInfo, "CurrentTransportState")),
|
|
3706
|
+
...positionSec !== void 0 ? { positionSec } : {},
|
|
3707
|
+
...durationSec ? { durationSec } : {},
|
|
3708
|
+
...volumeInfo && Number.isFinite(volume) ? { volume: Math.min(Math.max(volume / 100, 0), 1) } : {},
|
|
3709
|
+
...transportStatus && transportStatus !== "OK" ? { error: transportStatus } : {}
|
|
3710
|
+
};
|
|
3711
|
+
},
|
|
3712
|
+
// UPnP control is stateless: there is no connection to leave.
|
|
3713
|
+
close: async () => void 0
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
function playerState(value) {
|
|
3717
|
+
switch (value) {
|
|
3718
|
+
case "PLAYING":
|
|
3719
|
+
return "playing";
|
|
3720
|
+
case "PAUSED_PLAYBACK":
|
|
3721
|
+
case "PAUSED_RECORDING":
|
|
3722
|
+
return "paused";
|
|
3723
|
+
case "TRANSITIONING":
|
|
3724
|
+
return "buffering";
|
|
3725
|
+
case "STOPPED":
|
|
3726
|
+
return "stopped";
|
|
3727
|
+
default:
|
|
3728
|
+
return "idle";
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
function didlMetadata(media) {
|
|
3732
|
+
const upnpClass = media.kind === "video" ? "object.item.videoItem" : "object.item.audioItem.musicTrack";
|
|
3733
|
+
const duration = media.durationSec && !media.live ? ` duration="${formatRemoteClock(media.durationSec)}.000"` : "";
|
|
3734
|
+
return `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="0" parentID="-1" restricted="1"><dc:title>${escapeXmlText(media.title)}</dc:title>` + (media.artist ? `<upnp:artist>${escapeXmlText(media.artist)}</upnp:artist>` : "") + (media.album ? `<upnp:album>${escapeXmlText(media.album)}</upnp:album>` : "") + (media.artworkUrl ? `<upnp:albumArtURI>${escapeXmlText(media.artworkUrl)}</upnp:albumArtURI>` : "") + `<upnp:class>${upnpClass}</upnp:class><res protocolInfo="http-get:*:${escapeXmlText(media.contentType)}:*"${duration}>${escapeXmlText(media.url)}</res></item></DIDL-Lite>`;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
// src/online/mdns.ts
|
|
3738
|
+
var MDNS_ADDRESS = "224.0.0.251";
|
|
3739
|
+
var MDNS_PORT = 5353;
|
|
3740
|
+
function encodeMdnsQuery(serviceType, id = 1 + Math.floor(Math.random() * 65534)) {
|
|
3741
|
+
const bytes = [id >> 8, id & 255, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];
|
|
3742
|
+
for (const label of serviceType.replace(/\.$/, "").split(".")) {
|
|
3743
|
+
const encoded = new TextEncoder().encode(label);
|
|
3744
|
+
if (!encoded.length || encoded.length > 63) throw new Error("Invalid service type");
|
|
3745
|
+
bytes.push(encoded.length, ...encoded);
|
|
3746
|
+
}
|
|
3747
|
+
bytes.push(0, 0, 12, 0, 1);
|
|
3748
|
+
return new Uint8Array(bytes);
|
|
3749
|
+
}
|
|
3750
|
+
function parseMdnsQuestions(bytes) {
|
|
3751
|
+
try {
|
|
3752
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3753
|
+
if (view.getUint16(2) & 32768) return [];
|
|
3754
|
+
const questions = [];
|
|
3755
|
+
let offset = 12;
|
|
3756
|
+
for (let index = 0; index < Math.min(view.getUint16(4), 32); index++) {
|
|
3757
|
+
const { name, next } = readName(bytes, offset);
|
|
3758
|
+
questions.push({ name, type: view.getUint16(next) });
|
|
3759
|
+
offset = next + 4;
|
|
3760
|
+
}
|
|
3761
|
+
return questions;
|
|
3762
|
+
} catch {
|
|
3763
|
+
return [];
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
function encodeMdnsResponse(records, options = {}) {
|
|
3767
|
+
const bytes = [];
|
|
3768
|
+
const u16 = (value) => bytes.push(value >> 8 & 255, value & 255);
|
|
3769
|
+
const u32 = (value) => bytes.push(value >>> 24 & 255, value >> 16 & 255, value >> 8 & 255, value & 255);
|
|
3770
|
+
const name = (value, into = bytes) => {
|
|
3771
|
+
for (const label of value.replace(/\.$/, "").split(".")) {
|
|
3772
|
+
const encoded = new TextEncoder().encode(label);
|
|
3773
|
+
if (!encoded.length || encoded.length > 63) throw new Error("Invalid DNS name");
|
|
3774
|
+
into.push(encoded.length, ...encoded);
|
|
3775
|
+
}
|
|
3776
|
+
into.push(0);
|
|
3777
|
+
};
|
|
3778
|
+
u16(options.id ?? 0);
|
|
3779
|
+
u16(33792);
|
|
3780
|
+
u16(options.question ? 1 : 0);
|
|
3781
|
+
u16(records.length);
|
|
3782
|
+
u16(0);
|
|
3783
|
+
u16(0);
|
|
3784
|
+
if (options.question) {
|
|
3785
|
+
name(options.question.name);
|
|
3786
|
+
u16(options.question.type);
|
|
3787
|
+
u16(1);
|
|
3788
|
+
}
|
|
3789
|
+
for (const record of records) {
|
|
3790
|
+
const data = [];
|
|
3791
|
+
if (record.type === "PTR") name(record.target, data);
|
|
3792
|
+
if (record.type === "SRV") {
|
|
3793
|
+
data.push(0, 0, 0, 0, record.port >> 8 & 255, record.port & 255);
|
|
3794
|
+
name(record.target, data);
|
|
3795
|
+
}
|
|
3796
|
+
if (record.type === "TXT") {
|
|
3797
|
+
for (const [key, value] of Object.entries(record.entries)) {
|
|
3798
|
+
const entry = new TextEncoder().encode(`${key}=${value}`).subarray(0, 255);
|
|
3799
|
+
data.push(entry.length, ...entry);
|
|
3800
|
+
}
|
|
3801
|
+
if (!data.length) data.push(0);
|
|
3802
|
+
}
|
|
3803
|
+
if (record.type === "A") data.push(...record.address.split(".").map((part) => Number(part) & 255));
|
|
3804
|
+
name(record.name);
|
|
3805
|
+
u16({ PTR: 12, SRV: 33, TXT: 16, A: 1 }[record.type]);
|
|
3806
|
+
u16(1);
|
|
3807
|
+
u32(options.ttl ?? (record.type === "SRV" || record.type === "A" ? 120 : 4500));
|
|
3808
|
+
u16(data.length);
|
|
3809
|
+
bytes.push(...data);
|
|
3810
|
+
}
|
|
3811
|
+
return new Uint8Array(bytes);
|
|
3812
|
+
}
|
|
3813
|
+
function parseMdnsMessage(bytes) {
|
|
3814
|
+
try {
|
|
3815
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3816
|
+
const questions = view.getUint16(4);
|
|
3817
|
+
const records = view.getUint16(6) + view.getUint16(8) + view.getUint16(10);
|
|
3818
|
+
let offset = 12;
|
|
3819
|
+
for (let index = 0; index < questions; index++) {
|
|
3820
|
+
offset = readName(bytes, offset).next + 4;
|
|
3821
|
+
}
|
|
3822
|
+
const result = [];
|
|
3823
|
+
for (let index = 0; index < records; index++) {
|
|
3824
|
+
const { name, next } = readName(bytes, offset);
|
|
3825
|
+
const type = view.getUint16(next);
|
|
3826
|
+
const length = view.getUint16(next + 8);
|
|
3827
|
+
const data = next + 10;
|
|
3828
|
+
if (data + length > bytes.length) break;
|
|
3829
|
+
if (type === 12) {
|
|
3830
|
+
result.push({ type: "PTR", name, target: readName(bytes, data).name });
|
|
3831
|
+
} else if (type === 33) {
|
|
3832
|
+
result.push({ type: "SRV", name, port: view.getUint16(data + 4), target: readName(bytes, data + 6).name });
|
|
3833
|
+
} else if (type === 16) {
|
|
3834
|
+
result.push({ type: "TXT", name, entries: readTxt(bytes.subarray(data, data + length)) });
|
|
3835
|
+
} else if (type === 1 && length === 4) {
|
|
3836
|
+
result.push({ type: "A", name, address: [...bytes.subarray(data, data + 4)].join(".") });
|
|
3837
|
+
}
|
|
3838
|
+
offset = data + length;
|
|
3839
|
+
}
|
|
3840
|
+
return result;
|
|
3841
|
+
} catch {
|
|
3842
|
+
return [];
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
function mdnsServices(serviceType, answers) {
|
|
3846
|
+
const type = normalize(serviceType);
|
|
3847
|
+
const services = /* @__PURE__ */ new Map();
|
|
3848
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
3849
|
+
const pending = answers.map((answer) => ({ records: parseMdnsMessage(answer.bytes), ...answer.from ? { from: answer.from } : {} }));
|
|
3850
|
+
for (const { records } of pending) {
|
|
3851
|
+
for (const record of records) {
|
|
3852
|
+
if (record.type === "A") addresses.set(normalize(record.name), record.address);
|
|
3853
|
+
if (record.type === "PTR" && normalize(record.name) === type && !services.has(normalize(record.target))) {
|
|
3854
|
+
services.set(normalize(record.target), { instance: record.target.replace(/\.$/, ""), txt: {} });
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
for (const { records, from } of pending) {
|
|
3859
|
+
for (const record of records) {
|
|
3860
|
+
const service = services.get(normalize(record.name));
|
|
3861
|
+
if (!service) continue;
|
|
3862
|
+
if (record.type === "SRV") {
|
|
3863
|
+
service.host = record.target.replace(/\.$/, "");
|
|
3864
|
+
service.port = record.port;
|
|
3865
|
+
if (!service.address && from) service.address = from;
|
|
3866
|
+
}
|
|
3867
|
+
if (record.type === "TXT") service.txt = { ...service.txt, ...record.entries };
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
for (const service of services.values()) {
|
|
3871
|
+
const address = service.host ? addresses.get(normalize(service.host)) : void 0;
|
|
3872
|
+
if (address) service.address = address;
|
|
3873
|
+
}
|
|
3874
|
+
return [...services.values()];
|
|
3875
|
+
}
|
|
3876
|
+
function readName(bytes, start) {
|
|
3877
|
+
const labels = [];
|
|
3878
|
+
let offset = start;
|
|
3879
|
+
let next = -1;
|
|
3880
|
+
for (let jumps = 0; jumps < 32; jumps++) {
|
|
3881
|
+
const length = bytes[offset];
|
|
3882
|
+
if (length === void 0) throw new Error("Truncated name");
|
|
3883
|
+
if (length === 0) {
|
|
3884
|
+
return { name: labels.join("."), next: next >= 0 ? next : offset + 1 };
|
|
3885
|
+
}
|
|
3886
|
+
if ((length & 192) === 192) {
|
|
3887
|
+
if (next < 0) next = offset + 2;
|
|
3888
|
+
offset = (length & 63) << 8 | bytes[offset + 1];
|
|
3889
|
+
continue;
|
|
3890
|
+
}
|
|
3891
|
+
labels.push(new TextDecoder().decode(bytes.subarray(offset + 1, offset + 1 + length)));
|
|
3892
|
+
offset += 1 + length;
|
|
3893
|
+
}
|
|
3894
|
+
throw new Error("Name compression loop");
|
|
3895
|
+
}
|
|
3896
|
+
function readTxt(bytes) {
|
|
3897
|
+
const entries = {};
|
|
3898
|
+
let offset = 0;
|
|
3899
|
+
while (offset < bytes.length) {
|
|
3900
|
+
const length = bytes[offset];
|
|
3901
|
+
const entry = new TextDecoder().decode(bytes.subarray(offset + 1, offset + 1 + length));
|
|
3902
|
+
offset += 1 + length;
|
|
3903
|
+
const separator = entry.indexOf("=");
|
|
3904
|
+
if (separator > 0) entries[entry.slice(0, separator).toLowerCase()] = entry.slice(separator + 1);
|
|
3905
|
+
else if (entry) entries[entry.toLowerCase()] = "";
|
|
3906
|
+
}
|
|
3907
|
+
return entries;
|
|
3908
|
+
}
|
|
3909
|
+
function normalize(name) {
|
|
3910
|
+
return name.replace(/\.$/, "").toLowerCase();
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
// src/online/cast-v2.ts
|
|
3914
|
+
var CAST_MDNS_SERVICE = "_googlecast._tcp";
|
|
3915
|
+
var CAST_PORT = 8009;
|
|
3916
|
+
var CAST_DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
3917
|
+
var NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
|
3918
|
+
var NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
|
|
3919
|
+
var NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
|
|
3920
|
+
var NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
|
3921
|
+
var RECEIVER_ID = "receiver-0";
|
|
3922
|
+
var FAILURES = /* @__PURE__ */ new Set(["LOAD_FAILED", "LOAD_CANCELLED", "INVALID_REQUEST", "INVALID_PLAYER_STATE", "LAUNCH_ERROR"]);
|
|
3923
|
+
var CAST_UNSUPPORTED_TYPES = /* @__PURE__ */ new Map([
|
|
3924
|
+
["video/x-msvideo", "AVI"],
|
|
3925
|
+
["video/avi", "AVI"],
|
|
3926
|
+
["video/x-ms-wmv", "WMV"],
|
|
3927
|
+
["video/x-ms-asf", "ASF"],
|
|
3928
|
+
["audio/x-ms-wma", "WMA"],
|
|
3929
|
+
["video/x-flv", "FLV"]
|
|
3930
|
+
]);
|
|
3931
|
+
var CAST_TYPE_ALIASES = /* @__PURE__ */ new Map([
|
|
3932
|
+
["video/x-matroska", "video/mp4"],
|
|
3933
|
+
["video/quicktime", "video/mp4"]
|
|
3934
|
+
]);
|
|
3935
|
+
function castContentType(contentType) {
|
|
3936
|
+
const type = contentType.split(";")[0].trim().toLowerCase();
|
|
3937
|
+
const unsupported = CAST_UNSUPPORTED_TYPES.get(type);
|
|
3938
|
+
if (unsupported) return { unsupported };
|
|
3939
|
+
return { contentType: CAST_TYPE_ALIASES.get(type) ?? contentType };
|
|
3940
|
+
}
|
|
3941
|
+
function castDevicesFromMdns(answers) {
|
|
3942
|
+
const devices = /* @__PURE__ */ new Map();
|
|
3943
|
+
for (const service of mdnsServices(`${CAST_MDNS_SERVICE}.local`, answers)) {
|
|
3944
|
+
if (!service.address) continue;
|
|
3945
|
+
const id = service.txt["id"] || service.instance;
|
|
3946
|
+
const capabilities = Number(service.txt["ca"]);
|
|
3947
|
+
devices.set(id, {
|
|
3948
|
+
id,
|
|
3949
|
+
name: service.txt["fn"] || service.instance.split("._")[0],
|
|
3950
|
+
...service.txt["md"] ? { model: service.txt["md"] } : {},
|
|
3951
|
+
address: service.address,
|
|
3952
|
+
port: service.port ?? CAST_PORT,
|
|
3953
|
+
// Capability bit 0 is video output; unknown capabilities are treated as a screen.
|
|
3954
|
+
video: Number.isFinite(capabilities) ? (capabilities & 1) === 1 : true
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
3957
|
+
return [...devices.values()];
|
|
3958
|
+
}
|
|
3959
|
+
function encodeCastMessage(message) {
|
|
3960
|
+
const body = [];
|
|
3961
|
+
const text6 = new TextEncoder();
|
|
3962
|
+
const varint = (value) => {
|
|
3963
|
+
let rest = value >>> 0;
|
|
3964
|
+
while (rest > 127) {
|
|
3965
|
+
body.push(rest & 127 | 128);
|
|
3966
|
+
rest >>>= 7;
|
|
3967
|
+
}
|
|
3968
|
+
body.push(rest);
|
|
3969
|
+
};
|
|
3970
|
+
const string = (field, value) => {
|
|
3971
|
+
const bytes = text6.encode(value);
|
|
3972
|
+
varint(field << 3 | 2);
|
|
3973
|
+
varint(bytes.length);
|
|
3974
|
+
body.push(...bytes);
|
|
3975
|
+
};
|
|
3976
|
+
varint(1 << 3);
|
|
3977
|
+
varint(0);
|
|
3978
|
+
string(2, message.sourceId);
|
|
3979
|
+
string(3, message.destinationId);
|
|
3980
|
+
string(4, message.namespace);
|
|
3981
|
+
varint(5 << 3);
|
|
3982
|
+
varint(0);
|
|
3983
|
+
string(6, message.payload);
|
|
3984
|
+
const frame = new Uint8Array(4 + body.length);
|
|
3985
|
+
new DataView(frame.buffer).setUint32(0, body.length);
|
|
3986
|
+
frame.set(body, 4);
|
|
3987
|
+
return frame;
|
|
3988
|
+
}
|
|
3989
|
+
function decodeCastFrames(buffer) {
|
|
3990
|
+
const messages = [];
|
|
3991
|
+
let offset = 0;
|
|
3992
|
+
while (buffer.length - offset >= 4) {
|
|
3993
|
+
const length = new DataView(buffer.buffer, buffer.byteOffset + offset, 4).getUint32(0);
|
|
3994
|
+
if (buffer.length - offset - 4 < length) break;
|
|
3995
|
+
const message = decodeCastMessage(buffer.subarray(offset + 4, offset + 4 + length));
|
|
3996
|
+
if (message) messages.push(message);
|
|
3997
|
+
offset += 4 + length;
|
|
3998
|
+
}
|
|
3999
|
+
return { messages, rest: buffer.slice(offset) };
|
|
4000
|
+
}
|
|
4001
|
+
function decodeCastMessage(bytes) {
|
|
4002
|
+
const text6 = new TextDecoder();
|
|
4003
|
+
const message = {};
|
|
4004
|
+
let offset = 0;
|
|
4005
|
+
const varint = () => {
|
|
4006
|
+
let value = 0;
|
|
4007
|
+
let shift = 0;
|
|
4008
|
+
while (offset < bytes.length) {
|
|
4009
|
+
const byte = bytes[offset++];
|
|
4010
|
+
value += (byte & 127) * 2 ** shift;
|
|
4011
|
+
if (byte < 128) return value;
|
|
4012
|
+
shift += 7;
|
|
4013
|
+
}
|
|
4014
|
+
throw new Error("Truncated varint");
|
|
4015
|
+
};
|
|
4016
|
+
try {
|
|
4017
|
+
while (offset < bytes.length) {
|
|
4018
|
+
const key = varint();
|
|
4019
|
+
const field = Math.floor(key / 8);
|
|
4020
|
+
const wireType = key & 7;
|
|
4021
|
+
if (wireType === 0) {
|
|
4022
|
+
varint();
|
|
4023
|
+
} else if (wireType === 2) {
|
|
4024
|
+
const length = varint();
|
|
4025
|
+
const value = bytes.subarray(offset, offset + length);
|
|
4026
|
+
offset += length;
|
|
4027
|
+
if (field === 2) message.sourceId = text6.decode(value);
|
|
4028
|
+
else if (field === 3) message.destinationId = text6.decode(value);
|
|
4029
|
+
else if (field === 4) message.namespace = text6.decode(value);
|
|
4030
|
+
else if (field === 6) message.payload = text6.decode(value);
|
|
4031
|
+
} else if (wireType === 5) {
|
|
4032
|
+
offset += 4;
|
|
4033
|
+
} else if (wireType === 1) {
|
|
4034
|
+
offset += 8;
|
|
4035
|
+
} else {
|
|
4036
|
+
return null;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
} catch {
|
|
4040
|
+
return null;
|
|
4041
|
+
}
|
|
4042
|
+
return message.namespace && message.payload !== void 0 ? { sourceId: message.sourceId ?? "", destinationId: message.destinationId ?? "", namespace: message.namespace, payload: message.payload } : null;
|
|
4043
|
+
}
|
|
4044
|
+
function createCastSession(socket, options = {}) {
|
|
4045
|
+
const appId = options.appId ?? CAST_DEFAULT_MEDIA_RECEIVER;
|
|
4046
|
+
const senderId = options.senderId ?? `sender-${Math.random().toString(36).slice(2, 10)}`;
|
|
4047
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
|
|
4048
|
+
const loadTimeoutMs = options.loadTimeoutMs ?? Math.max(requestTimeoutMs, 3e4);
|
|
4049
|
+
const pending = /* @__PURE__ */ new Map();
|
|
4050
|
+
let buffer = new Uint8Array(0);
|
|
4051
|
+
let nextRequestId = 1;
|
|
4052
|
+
let closed = null;
|
|
4053
|
+
let transportId = null;
|
|
4054
|
+
let appSessionId = null;
|
|
4055
|
+
let mediaSessionId = null;
|
|
4056
|
+
let lastMedia = null;
|
|
4057
|
+
let loaded = null;
|
|
4058
|
+
let volume;
|
|
4059
|
+
const connected = /* @__PURE__ */ new Set();
|
|
4060
|
+
let heartbeat;
|
|
4061
|
+
const send = (destinationId, namespace, payload) => {
|
|
4062
|
+
if (closed) throw new Error(closed);
|
|
4063
|
+
return socket.send(encodeCastMessage({ sourceId: senderId, destinationId, namespace, payload: JSON.stringify(payload) }));
|
|
4064
|
+
};
|
|
4065
|
+
const connect = async (destinationId) => {
|
|
4066
|
+
if (connected.has(destinationId)) return;
|
|
4067
|
+
await send(destinationId, NS_CONNECTION, { type: "CONNECT", origin: {} });
|
|
4068
|
+
connected.add(destinationId);
|
|
4069
|
+
};
|
|
4070
|
+
const request2 = async (destinationId, namespace, payload, timeoutMs = requestTimeoutMs) => {
|
|
4071
|
+
const requestId = nextRequestId++;
|
|
4072
|
+
const answer = new Promise((resolve, reject) => {
|
|
4073
|
+
const timer = setTimeout(() => {
|
|
4074
|
+
pending.delete(requestId);
|
|
4075
|
+
reject(new RemotePlaybackError("timeout", "The Cast device did not answer"));
|
|
4076
|
+
}, timeoutMs);
|
|
4077
|
+
pending.set(requestId, { resolve, reject, timer });
|
|
4078
|
+
});
|
|
4079
|
+
try {
|
|
4080
|
+
await send(destinationId, namespace, { ...payload, requestId });
|
|
4081
|
+
} catch (error) {
|
|
4082
|
+
clearTimeout(pending.get(requestId)?.timer);
|
|
4083
|
+
pending.delete(requestId);
|
|
4084
|
+
throw error;
|
|
4085
|
+
}
|
|
4086
|
+
return answer;
|
|
4087
|
+
};
|
|
4088
|
+
const shutdown = (reason) => {
|
|
4089
|
+
if (closed) return;
|
|
4090
|
+
closed = reason;
|
|
4091
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
4092
|
+
for (const [requestId, entry] of pending) {
|
|
4093
|
+
clearTimeout(entry.timer);
|
|
4094
|
+
entry.reject(new Error(reason));
|
|
4095
|
+
pending.delete(requestId);
|
|
4096
|
+
}
|
|
4097
|
+
};
|
|
4098
|
+
const remember = (payload) => {
|
|
4099
|
+
if (payload.type === "RECEIVER_STATUS") {
|
|
4100
|
+
const status = payload["status"];
|
|
4101
|
+
if (typeof status?.volume?.level === "number") volume = status.volume.level;
|
|
4102
|
+
const application = status?.applications?.find((entry) => entry.appId === appId);
|
|
4103
|
+
if (!application) {
|
|
4104
|
+
transportId = null;
|
|
4105
|
+
appSessionId = null;
|
|
4106
|
+
mediaSessionId = null;
|
|
4107
|
+
} else if (application.transportId) {
|
|
4108
|
+
transportId = application.transportId;
|
|
4109
|
+
appSessionId = application.sessionId ?? null;
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
if (payload.type === "MEDIA_STATUS") {
|
|
4113
|
+
const statuses = payload["status"] ?? [];
|
|
4114
|
+
const current = statuses.find((entry) => entry.mediaSessionId === mediaSessionId) ?? statuses[0];
|
|
4115
|
+
if (current?.mediaSessionId !== void 0) mediaSessionId = current.mediaSessionId;
|
|
4116
|
+
lastMedia = current ?? (lastMedia ? { ...lastMedia, playerState: "IDLE", idleReason: lastMedia.idleReason ?? "FINISHED" } : null);
|
|
4117
|
+
if (loaded && typeof current?.currentTime === "number") loaded = { ...loaded, positionSec: current.currentTime };
|
|
4118
|
+
}
|
|
4119
|
+
};
|
|
4120
|
+
const unsubscribeData = socket.onData((chunk) => {
|
|
4121
|
+
const joined = new Uint8Array(buffer.length + chunk.length);
|
|
4122
|
+
joined.set(buffer);
|
|
4123
|
+
joined.set(chunk, buffer.length);
|
|
4124
|
+
const { messages, rest } = decodeCastFrames(joined);
|
|
4125
|
+
buffer = rest;
|
|
4126
|
+
for (const message of messages) {
|
|
4127
|
+
let payload;
|
|
4128
|
+
try {
|
|
4129
|
+
payload = JSON.parse(message.payload);
|
|
4130
|
+
} catch {
|
|
4131
|
+
continue;
|
|
4132
|
+
}
|
|
4133
|
+
if (message.namespace === NS_HEARTBEAT && payload.type === "PING") {
|
|
4134
|
+
void Promise.resolve(send(message.sourceId, NS_HEARTBEAT, { type: "PONG" })).catch(() => void 0);
|
|
4135
|
+
continue;
|
|
4136
|
+
}
|
|
4137
|
+
if (message.namespace === NS_CONNECTION && payload.type === "CLOSE") {
|
|
4138
|
+
connected.delete(message.sourceId);
|
|
4139
|
+
if (message.sourceId === transportId) {
|
|
4140
|
+
transportId = null;
|
|
4141
|
+
mediaSessionId = null;
|
|
4142
|
+
}
|
|
4143
|
+
continue;
|
|
4144
|
+
}
|
|
4145
|
+
remember(payload);
|
|
4146
|
+
const entry = payload.requestId ? pending.get(payload.requestId) : void 0;
|
|
4147
|
+
if (!entry) continue;
|
|
4148
|
+
pending.delete(payload.requestId);
|
|
4149
|
+
clearTimeout(entry.timer);
|
|
4150
|
+
if (payload.type && FAILURES.has(payload.type)) {
|
|
4151
|
+
entry.reject(payload.type === "LOAD_FAILED" ? new RemotePlaybackError("load-failed", "The Cast device could not play this media") : new Error(`The Cast device refused the request (${payload.type})`));
|
|
4152
|
+
} else {
|
|
4153
|
+
entry.resolve(payload);
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
});
|
|
4157
|
+
const unsubscribeClose = socket.onClose((reason) => shutdown(reason ?? "The Cast device closed the connection"));
|
|
4158
|
+
const heartbeatMs = options.heartbeatMs ?? 5e3;
|
|
4159
|
+
if (heartbeatMs > 0) {
|
|
4160
|
+
heartbeat = setInterval(() => {
|
|
4161
|
+
if (!closed) void Promise.resolve(send(RECEIVER_ID, NS_HEARTBEAT, { type: "PING" })).catch(() => void 0);
|
|
4162
|
+
}, heartbeatMs);
|
|
4163
|
+
}
|
|
4164
|
+
const ready = connect(RECEIVER_ID);
|
|
4165
|
+
const launch = async () => {
|
|
4166
|
+
await ready;
|
|
4167
|
+
if (!transportId) remember(await request2(RECEIVER_ID, NS_RECEIVER, { type: "GET_STATUS" }));
|
|
4168
|
+
for (let attempt = 0; !transportId && attempt < 5; attempt++) {
|
|
4169
|
+
remember(attempt === 0 ? await request2(RECEIVER_ID, NS_RECEIVER, { type: "LAUNCH", appId }) : await request2(RECEIVER_ID, NS_RECEIVER, { type: "GET_STATUS" }));
|
|
4170
|
+
if (!transportId) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
4171
|
+
}
|
|
4172
|
+
if (!transportId) throw new Error("The Cast device did not start its media player");
|
|
4173
|
+
await connect(transportId);
|
|
4174
|
+
return transportId;
|
|
4175
|
+
};
|
|
4176
|
+
const sendMedia = async (media, positionSec) => {
|
|
4177
|
+
const type = castContentType(media.contentType);
|
|
4178
|
+
if ("unsupported" in type) {
|
|
4179
|
+
throw new RemotePlaybackError("unsupported-format", `Cast devices do not read ${type.unsupported} files`, type.unsupported);
|
|
4180
|
+
}
|
|
4181
|
+
const destination = await launch();
|
|
4182
|
+
const answer = await request2(destination, NS_MEDIA, {
|
|
4183
|
+
type: "LOAD",
|
|
4184
|
+
...appSessionId ? { sessionId: appSessionId } : {},
|
|
4185
|
+
autoplay: true,
|
|
4186
|
+
currentTime: media.live ? 0 : Math.max(0, positionSec),
|
|
4187
|
+
media: {
|
|
4188
|
+
contentId: media.url,
|
|
4189
|
+
contentUrl: media.url,
|
|
4190
|
+
contentType: type.contentType,
|
|
4191
|
+
streamType: media.live ? "LIVE" : "BUFFERED",
|
|
4192
|
+
...media.durationSec && !media.live ? { duration: media.durationSec } : {},
|
|
4193
|
+
metadata: {
|
|
4194
|
+
// 3: MusicTrackMediaMetadata, 0: GenericMediaMetadata.
|
|
4195
|
+
metadataType: media.kind === "audio" ? 3 : 0,
|
|
4196
|
+
title: media.title,
|
|
4197
|
+
...media.artist ? { artist: media.artist, subtitle: media.artist } : {},
|
|
4198
|
+
...media.album ? { albumName: media.album } : {},
|
|
4199
|
+
...media.artworkUrl ? { images: [{ url: media.artworkUrl }] } : {}
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
}, loadTimeoutMs);
|
|
4203
|
+
loaded = { media, positionSec: media.live ? 0 : Math.max(0, positionSec) };
|
|
4204
|
+
remember(answer);
|
|
4205
|
+
};
|
|
4206
|
+
const mediaCommand = async (type, extra = {}) => {
|
|
4207
|
+
if ((!transportId || mediaSessionId === null) && loaded && type !== "STOP") {
|
|
4208
|
+
await sendMedia(loaded.media, loaded.positionSec);
|
|
4209
|
+
if (type === "PLAY") return;
|
|
4210
|
+
}
|
|
4211
|
+
if (!transportId || mediaSessionId === null) throw new Error("Nothing is playing on the Cast device");
|
|
4212
|
+
remember(await request2(transportId, NS_MEDIA, { type, mediaSessionId, ...extra }));
|
|
4213
|
+
};
|
|
4214
|
+
return {
|
|
4215
|
+
kind: "chromecast",
|
|
4216
|
+
load: (media) => sendMedia(media, media.live ? 0 : media.startSec ?? 0),
|
|
4217
|
+
play: () => mediaCommand("PLAY"),
|
|
4218
|
+
pause: () => mediaCommand("PAUSE"),
|
|
4219
|
+
seek: (positionSec) => mediaCommand("SEEK", { currentTime: Math.max(0, positionSec) }),
|
|
4220
|
+
async setVolume(level) {
|
|
4221
|
+
await ready;
|
|
4222
|
+
remember(await request2(RECEIVER_ID, NS_RECEIVER, { type: "SET_VOLUME", volume: { level: Math.min(Math.max(level, 0), 1) } }));
|
|
4223
|
+
},
|
|
4224
|
+
async stop() {
|
|
4225
|
+
if (transportId && mediaSessionId !== null) await mediaCommand("STOP").catch(() => void 0);
|
|
4226
|
+
if (appSessionId) remember(await request2(RECEIVER_ID, NS_RECEIVER, { type: "STOP", sessionId: appSessionId }).catch(() => ({})));
|
|
4227
|
+
mediaSessionId = null;
|
|
4228
|
+
lastMedia = null;
|
|
4229
|
+
loaded = null;
|
|
4230
|
+
},
|
|
4231
|
+
async status() {
|
|
4232
|
+
if (transportId && mediaSessionId !== null) {
|
|
4233
|
+
remember(await request2(transportId, NS_MEDIA, { type: "GET_STATUS", mediaSessionId }));
|
|
4234
|
+
} else {
|
|
4235
|
+
await ready;
|
|
4236
|
+
remember(await request2(RECEIVER_ID, NS_RECEIVER, { type: "GET_STATUS" }));
|
|
4237
|
+
}
|
|
4238
|
+
return castStatus(lastMedia, volume);
|
|
4239
|
+
},
|
|
4240
|
+
async close() {
|
|
4241
|
+
for (const destination of [...connected].reverse()) {
|
|
4242
|
+
await Promise.resolve(send(destination, NS_CONNECTION, { type: "CLOSE" })).catch(() => void 0);
|
|
4243
|
+
}
|
|
4244
|
+
shutdown("The Cast session is closed");
|
|
4245
|
+
unsubscribeData();
|
|
4246
|
+
unsubscribeClose();
|
|
4247
|
+
socket.close();
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
4250
|
+
}
|
|
4251
|
+
function castStatus(media, volume) {
|
|
4252
|
+
const base = volume !== void 0 ? { volume } : {};
|
|
4253
|
+
if (!media) return { state: "idle", ...base };
|
|
4254
|
+
const states = { PLAYING: "playing", PAUSED: "paused", BUFFERING: "buffering", LOADING: "loading" };
|
|
4255
|
+
const state = states[media.playerState ?? ""] ?? (media.idleReason ? "stopped" : "idle");
|
|
4256
|
+
return {
|
|
4257
|
+
state,
|
|
4258
|
+
...base,
|
|
4259
|
+
...typeof media.currentTime === "number" ? { positionSec: media.currentTime } : {},
|
|
4260
|
+
...media.media?.duration ? { durationSec: media.media.duration } : {},
|
|
4261
|
+
...media.idleReason === "ERROR" ? { error: "The Cast device could not play this media" } : {},
|
|
4262
|
+
...media.idleReason === "FINISHED" ? { ended: true } : {}
|
|
4263
|
+
};
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
// src/online/playback-transfer.ts
|
|
4267
|
+
var NGXSP_SERVICE_TYPE = "_ngxsp._tcp.local";
|
|
4268
|
+
var PLAYBACK_TRANSFER_PATH = "/ngxsp/transfer";
|
|
4269
|
+
var PLAYBACK_TRANSFER_VERSION = 1;
|
|
4270
|
+
var PLAYBACK_TRANSFER_PROMPT_MS = 6e4;
|
|
4271
|
+
var MAX_ITEMS = 200;
|
|
4272
|
+
var MAX_TEXT = 300;
|
|
4273
|
+
function ngxspTxt(identity) {
|
|
4274
|
+
return { v: String(PLAYBACK_TRANSFER_VERSION), id: identity.id, app: identity.app, name: identity.name.slice(0, 120) };
|
|
4275
|
+
}
|
|
4276
|
+
function ngxspServiceRecords(identity, port, addresses) {
|
|
4277
|
+
const label = `${identity.name.replace(/[.\s]+/g, " ").trim().slice(0, 40) || "NGXSP"} (NGXSP ${identity.app === "audio" ? "Audio" : "Video"})`;
|
|
4278
|
+
const instance = `${label}.${NGXSP_SERVICE_TYPE}`;
|
|
4279
|
+
const host = `ngxsp-${identity.id.replace(/[^a-z0-9]/gi, "").slice(0, 12).toLowerCase() || "app"}.local`;
|
|
4280
|
+
return [
|
|
4281
|
+
{ type: "PTR", name: NGXSP_SERVICE_TYPE, target: instance },
|
|
4282
|
+
{ type: "SRV", name: instance, target: host, port },
|
|
4283
|
+
{ type: "TXT", name: instance, entries: ngxspTxt(identity) },
|
|
4284
|
+
...addresses.map((address) => ({ type: "A", name: host, address }))
|
|
4285
|
+
];
|
|
4286
|
+
}
|
|
4287
|
+
function ngxspPeersFromMdns(answers, self) {
|
|
4288
|
+
const peers = /* @__PURE__ */ new Map();
|
|
4289
|
+
for (const service of mdnsServices(NGXSP_SERVICE_TYPE, answers)) {
|
|
4290
|
+
const { id, app, name } = service.txt;
|
|
4291
|
+
if (!id || id === self.id || app !== self.app || !service.address || !service.port) continue;
|
|
4292
|
+
peers.set(id, { id, app, name: name || service.instance.split(".")[0], address: service.address, port: service.port });
|
|
4293
|
+
}
|
|
4294
|
+
return [...peers.values()];
|
|
4295
|
+
}
|
|
4296
|
+
function readPlaybackTransfer(value) {
|
|
4297
|
+
if (!value || typeof value !== "object") return null;
|
|
4298
|
+
const input = value;
|
|
4299
|
+
const from = input["from"];
|
|
4300
|
+
const app = from?.["app"];
|
|
4301
|
+
if (input["version"] !== PLAYBACK_TRANSFER_VERSION || !from || typeof from["id"] !== "string" || !from["id"] || app !== "audio" && app !== "video") return null;
|
|
4302
|
+
const items = Array.isArray(input["items"]) ? input["items"].slice(0, MAX_ITEMS).flatMap((item) => {
|
|
4303
|
+
const entry = readItem(item);
|
|
4304
|
+
return entry ? [entry] : [];
|
|
4305
|
+
}) : [];
|
|
4306
|
+
if (!items.length) return null;
|
|
4307
|
+
const index = Number(input["index"]);
|
|
4308
|
+
const positionSec = Number(input["positionSec"]);
|
|
4309
|
+
return {
|
|
4310
|
+
version: PLAYBACK_TRANSFER_VERSION,
|
|
4311
|
+
from: { id: text2(from["id"]).slice(0, 64), name: text2(from["name"]) || text2(from["id"]).slice(0, 8), app },
|
|
4312
|
+
items,
|
|
4313
|
+
index: Number.isInteger(index) && index >= 0 && index < items.length ? index : 0,
|
|
4314
|
+
positionSec: Number.isFinite(positionSec) && positionSec > 0 ? positionSec : 0,
|
|
4315
|
+
playing: input["playing"] !== false
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
function readItem(value) {
|
|
4319
|
+
if (!value || typeof value !== "object") return null;
|
|
4320
|
+
const input = value;
|
|
4321
|
+
const url = text2(input["url"], 4e3);
|
|
4322
|
+
if (!/^https?:\/\//i.test(url)) return null;
|
|
4323
|
+
const artworkUrl = text2(input["artworkUrl"], 4e3);
|
|
4324
|
+
const durationSec = Number(input["durationSec"]);
|
|
4325
|
+
return {
|
|
4326
|
+
url,
|
|
4327
|
+
title: text2(input["title"]) || url.split(/[?#]/)[0].split("/").pop() || url,
|
|
4328
|
+
...text2(input["artist"]) ? { artist: text2(input["artist"]) } : {},
|
|
4329
|
+
...text2(input["album"]) ? { album: text2(input["album"]) } : {},
|
|
4330
|
+
.../^https?:\/\//i.test(artworkUrl) ? { artworkUrl } : {},
|
|
4331
|
+
...Number.isFinite(durationSec) && durationSec > 0 ? { durationSec } : {},
|
|
4332
|
+
...input["live"] === true ? { live: true } : {}
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
4335
|
+
function text2(value, max = MAX_TEXT) {
|
|
4336
|
+
return typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").trim().slice(0, max) : "";
|
|
4337
|
+
}
|
|
4338
|
+
function createPlaybackTransferReceiver(options) {
|
|
4339
|
+
let asking = false;
|
|
4340
|
+
async function isTrusted(from, authorization) {
|
|
4341
|
+
const token = /^NGXSP\s+(\S{16,200})$/.exec(authorization?.trim() ?? "")?.[1];
|
|
4342
|
+
const sender = options.trusted().find((entry) => entry.id === from.id);
|
|
4343
|
+
return !!token && !!sender && await options.sha256Hex(token) === sender.tokenHash;
|
|
4344
|
+
}
|
|
4345
|
+
async function handle(request2) {
|
|
4346
|
+
if (request2.method.toUpperCase() !== "POST") return { status: 405, body: { accepted: false, reason: "invalid" } };
|
|
4347
|
+
if (!options.enabled()) return { status: 403, body: { accepted: false, reason: "disabled" } };
|
|
4348
|
+
let transfer = null;
|
|
4349
|
+
try {
|
|
4350
|
+
transfer = readPlaybackTransfer(JSON.parse(request2.body));
|
|
4351
|
+
} catch {
|
|
4352
|
+
transfer = null;
|
|
4353
|
+
}
|
|
4354
|
+
if (!transfer) return { status: 400, body: { accepted: false, reason: "invalid" } };
|
|
4355
|
+
if (transfer.from.app !== options.app) return { status: 422, body: { accepted: false, reason: "other-app" } };
|
|
4356
|
+
if (await isTrusted(transfer.from, request2.authorization)) {
|
|
4357
|
+
const from = transfer.from;
|
|
4358
|
+
options.saveTrusted(options.trusted().map((entry) => entry.id === from.id ? { ...entry, name: from.name } : entry));
|
|
4359
|
+
options.apply(transfer);
|
|
4360
|
+
return { status: 200, body: { accepted: true } };
|
|
4361
|
+
}
|
|
4362
|
+
if (asking) return { status: 409, body: { accepted: false, reason: "busy" } };
|
|
4363
|
+
asking = true;
|
|
4364
|
+
let allowed;
|
|
4365
|
+
try {
|
|
4366
|
+
allowed = await Promise.race([
|
|
4367
|
+
options.ask(transfer.from),
|
|
4368
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), options.promptMs ?? PLAYBACK_TRANSFER_PROMPT_MS))
|
|
4369
|
+
]);
|
|
4370
|
+
} finally {
|
|
4371
|
+
asking = false;
|
|
4372
|
+
}
|
|
4373
|
+
if (allowed === "timeout") return { status: 408, body: { accepted: false, reason: "timeout" } };
|
|
4374
|
+
if (!allowed) return { status: 403, body: { accepted: false, reason: "refused" } };
|
|
4375
|
+
const token = options.randomToken();
|
|
4376
|
+
const sender = { id: transfer.from.id, name: transfer.from.name, tokenHash: await options.sha256Hex(token), since: Date.now() };
|
|
4377
|
+
options.saveTrusted([...options.trusted().filter((entry) => entry.id !== sender.id), sender]);
|
|
4378
|
+
options.apply(transfer);
|
|
4379
|
+
return { status: 200, body: { accepted: true, token } };
|
|
4380
|
+
}
|
|
4381
|
+
return { handle };
|
|
4382
|
+
}
|
|
4383
|
+
async function sendPlaybackTransfer(http, peer, transfer, token) {
|
|
4384
|
+
let response;
|
|
4385
|
+
try {
|
|
4386
|
+
response = await http.send({
|
|
4387
|
+
url: `http://${peer.address}:${peer.port}${PLAYBACK_TRANSFER_PATH}`,
|
|
4388
|
+
method: "POST",
|
|
4389
|
+
headers: { "Content-Type": "application/json", ...token ? { Authorization: `NGXSP ${token}` } : {} },
|
|
4390
|
+
body: JSON.stringify(transfer),
|
|
4391
|
+
// The other device may be waiting for its user.
|
|
4392
|
+
timeoutMs: PLAYBACK_TRANSFER_PROMPT_MS + 15e3
|
|
4393
|
+
});
|
|
4394
|
+
} catch {
|
|
4395
|
+
return { accepted: false, reason: "unreachable" };
|
|
4396
|
+
}
|
|
4397
|
+
let body = {};
|
|
4398
|
+
try {
|
|
4399
|
+
body = JSON.parse(await response.text());
|
|
4400
|
+
} catch {
|
|
4401
|
+
body = {};
|
|
4402
|
+
}
|
|
4403
|
+
if (response.status === 200 && body["accepted"] === true) {
|
|
4404
|
+
return { accepted: true, ...typeof body["token"] === "string" ? { token: body["token"] } : {} };
|
|
4405
|
+
}
|
|
4406
|
+
const reasons = ["refused", "busy", "timeout", "disabled", "other-app", "invalid"];
|
|
4407
|
+
const reason = reasons.find((entry) => entry === body["reason"]);
|
|
4408
|
+
return { accepted: false, reason: reason ?? "unreachable" };
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
// src/online/md5.ts
|
|
4412
|
+
function md5Hex(input) {
|
|
4413
|
+
const bytes = new TextEncoder().encode(input);
|
|
4414
|
+
const bitLength = bytes.length * 8;
|
|
4415
|
+
const paddedLength = ((bytes.length + 8 >>> 6) + 1) * 64;
|
|
4416
|
+
const buffer = new Uint8Array(paddedLength);
|
|
4417
|
+
buffer.set(bytes);
|
|
4418
|
+
buffer[bytes.length] = 128;
|
|
4419
|
+
const view = new DataView(buffer.buffer);
|
|
4420
|
+
view.setUint32(paddedLength - 8, bitLength >>> 0, true);
|
|
4421
|
+
view.setUint32(paddedLength - 4, Math.floor(bitLength / 4294967296), true);
|
|
4422
|
+
let a0 = 1732584193;
|
|
4423
|
+
let b0 = 4023233417;
|
|
4424
|
+
let c0 = 2562383102;
|
|
4425
|
+
let d0 = 271733878;
|
|
4426
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
4427
|
+
let a = a0;
|
|
4428
|
+
let b = b0;
|
|
4429
|
+
let c = c0;
|
|
4430
|
+
let d = d0;
|
|
4431
|
+
for (let index = 0; index < 64; index += 1) {
|
|
4432
|
+
let f;
|
|
4433
|
+
let g;
|
|
4434
|
+
if (index < 16) {
|
|
4435
|
+
f = b & c | ~b & d;
|
|
4436
|
+
g = index;
|
|
4437
|
+
} else if (index < 32) {
|
|
4438
|
+
f = d & b | ~d & c;
|
|
4439
|
+
g = (5 * index + 1) % 16;
|
|
4440
|
+
} else if (index < 48) {
|
|
4441
|
+
f = b ^ c ^ d;
|
|
4442
|
+
g = (3 * index + 5) % 16;
|
|
4443
|
+
} else {
|
|
4444
|
+
f = c ^ (b | ~d);
|
|
4445
|
+
g = 7 * index % 16;
|
|
4446
|
+
}
|
|
4447
|
+
const rotated = a + f + K[index] + view.getUint32(offset + g * 4, true) | 0;
|
|
4448
|
+
a = d;
|
|
4449
|
+
d = c;
|
|
4450
|
+
c = b;
|
|
4451
|
+
b = b + (rotated << S[index] | rotated >>> 32 - S[index]) | 0;
|
|
4452
|
+
}
|
|
4453
|
+
a0 = a0 + a | 0;
|
|
4454
|
+
b0 = b0 + b | 0;
|
|
4455
|
+
c0 = c0 + c | 0;
|
|
4456
|
+
d0 = d0 + d | 0;
|
|
4457
|
+
}
|
|
4458
|
+
const digest = new Uint8Array(16);
|
|
4459
|
+
const out = new DataView(digest.buffer);
|
|
4460
|
+
[a0, b0, c0, d0].forEach((word, index) => out.setUint32(index * 4, word >>> 0, true));
|
|
4461
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4462
|
+
}
|
|
4463
|
+
var S = [
|
|
4464
|
+
7,
|
|
4465
|
+
12,
|
|
4466
|
+
17,
|
|
4467
|
+
22,
|
|
4468
|
+
7,
|
|
4469
|
+
12,
|
|
4470
|
+
17,
|
|
4471
|
+
22,
|
|
4472
|
+
7,
|
|
4473
|
+
12,
|
|
4474
|
+
17,
|
|
4475
|
+
22,
|
|
4476
|
+
7,
|
|
4477
|
+
12,
|
|
4478
|
+
17,
|
|
4479
|
+
22,
|
|
4480
|
+
5,
|
|
4481
|
+
9,
|
|
4482
|
+
14,
|
|
4483
|
+
20,
|
|
4484
|
+
5,
|
|
4485
|
+
9,
|
|
4486
|
+
14,
|
|
4487
|
+
20,
|
|
4488
|
+
5,
|
|
4489
|
+
9,
|
|
4490
|
+
14,
|
|
4491
|
+
20,
|
|
4492
|
+
5,
|
|
4493
|
+
9,
|
|
4494
|
+
14,
|
|
4495
|
+
20,
|
|
4496
|
+
4,
|
|
4497
|
+
11,
|
|
4498
|
+
16,
|
|
4499
|
+
23,
|
|
4500
|
+
4,
|
|
4501
|
+
11,
|
|
4502
|
+
16,
|
|
4503
|
+
23,
|
|
4504
|
+
4,
|
|
4505
|
+
11,
|
|
4506
|
+
16,
|
|
4507
|
+
23,
|
|
4508
|
+
4,
|
|
4509
|
+
11,
|
|
4510
|
+
16,
|
|
4511
|
+
23,
|
|
4512
|
+
6,
|
|
4513
|
+
10,
|
|
4514
|
+
15,
|
|
4515
|
+
21,
|
|
4516
|
+
6,
|
|
4517
|
+
10,
|
|
4518
|
+
15,
|
|
4519
|
+
21,
|
|
4520
|
+
6,
|
|
4521
|
+
10,
|
|
4522
|
+
15,
|
|
4523
|
+
21,
|
|
4524
|
+
6,
|
|
4525
|
+
10,
|
|
4526
|
+
15,
|
|
4527
|
+
21
|
|
4528
|
+
];
|
|
4529
|
+
var K = Array.from({ length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 4294967296) | 0);
|
|
4530
|
+
|
|
4531
|
+
// src/online/subsonic.ts
|
|
4532
|
+
var SUBSONIC_API_VERSION = "1.16.1";
|
|
4533
|
+
var SUBSONIC_CLIENT = "NGXSP";
|
|
4534
|
+
var ALBUM_PREFIX = "album:";
|
|
4535
|
+
var PAGE_SIZE3 = 500;
|
|
4536
|
+
function createSubsonicConnector(salt = randomSalt) {
|
|
4537
|
+
return {
|
|
4538
|
+
kind: "subsonic",
|
|
4539
|
+
connect: (input, http) => connectSubsonic(input, http, salt),
|
|
4540
|
+
browse: (account, options, http) => browseSubsonic(account, options, http)
|
|
4541
|
+
};
|
|
4542
|
+
}
|
|
4543
|
+
async function connectSubsonic(input, http, salt = randomSalt) {
|
|
4544
|
+
const baseUrl = normalizeMediaSourceAddress(input.address);
|
|
4545
|
+
const username = input.username?.trim() ?? "";
|
|
4546
|
+
if (!username) throw new Error("A Subsonic server needs a user name");
|
|
4547
|
+
const signatureSalt = salt();
|
|
4548
|
+
const account = {
|
|
4549
|
+
id: `subsonic-${md5Hex(`${baseUrl}|${username}`).slice(0, 8)}`,
|
|
4550
|
+
kind: "subsonic",
|
|
4551
|
+
name: input.name?.trim() || new URL(baseUrl).host,
|
|
4552
|
+
baseUrl,
|
|
4553
|
+
username,
|
|
4554
|
+
secret: `${signatureSalt}:${md5Hex(`${input.password ?? ""}${signatureSalt}`)}`
|
|
4555
|
+
};
|
|
4556
|
+
const response = await call(http, account, "ping", {});
|
|
4557
|
+
const serverType = text3(response["type"]);
|
|
4558
|
+
return serverType && !input.name?.trim() ? { ...account, name: `${capitalize(serverType)} - ${new URL(baseUrl).host}` } : account;
|
|
4559
|
+
}
|
|
4560
|
+
async function browseSubsonic(account, options, http) {
|
|
4561
|
+
if (options.kind === "video") return { containers: [], items: [] };
|
|
4562
|
+
if (options.search) {
|
|
4563
|
+
const response2 = await call(http, account, "search3", { query: options.search, songCount: "100", albumCount: "0", artistCount: "0" }, options.signal);
|
|
4564
|
+
const songs = asArray(response2["searchResult3"]?.["song"]);
|
|
4565
|
+
return { containers: [], items: songs.flatMap((song) => itemOrEmpty(account, song)) };
|
|
4566
|
+
}
|
|
4567
|
+
if (options.containerId?.startsWith(ALBUM_PREFIX)) {
|
|
4568
|
+
const response2 = await call(http, account, "getAlbum", { id: options.containerId.slice(ALBUM_PREFIX.length) }, options.signal);
|
|
4569
|
+
const songs = asArray(response2["album"]?.["song"]);
|
|
4570
|
+
return { containers: [], items: songs.flatMap((song) => itemOrEmpty(account, song)), total: songs.length };
|
|
4571
|
+
}
|
|
4572
|
+
const response = await call(http, account, "getAlbumList2", {
|
|
4573
|
+
type: "alphabeticalByArtist",
|
|
4574
|
+
size: String(Math.min(PAGE_SIZE3, Math.max(1, options.limit ?? PAGE_SIZE3))),
|
|
4575
|
+
offset: String(Math.max(0, options.startIndex ?? 0))
|
|
4576
|
+
}, options.signal);
|
|
4577
|
+
const albums = asArray(response["albumList2"]?.["album"]);
|
|
4578
|
+
return {
|
|
4579
|
+
containers: albums.flatMap((album) => {
|
|
4580
|
+
const record = album ?? {};
|
|
4581
|
+
const id = text3(record["id"]);
|
|
4582
|
+
const title = text3(record["name"]) ?? text3(record["album"]);
|
|
4583
|
+
if (!id || !title) return [];
|
|
4584
|
+
const coverArt = text3(record["coverArt"]);
|
|
4585
|
+
const songCount = positiveInteger3(record["songCount"]);
|
|
4586
|
+
return [{
|
|
4587
|
+
id: `${ALBUM_PREFIX}${id}`,
|
|
4588
|
+
title,
|
|
4589
|
+
kind: "audio",
|
|
4590
|
+
...text3(record["artist"]) ? { subtitle: text3(record["artist"]) } : {},
|
|
4591
|
+
...coverArt ? { artworkUrl: subsonicCoverUrl(account, coverArt) } : {},
|
|
4592
|
+
...songCount ? { childCount: songCount } : {}
|
|
4593
|
+
}];
|
|
4594
|
+
}),
|
|
4595
|
+
items: []
|
|
4596
|
+
};
|
|
4597
|
+
}
|
|
4598
|
+
function subsonicStreamUrl(account, songId) {
|
|
4599
|
+
return subsonicUrl(account, "stream", { id: songId }, false);
|
|
4600
|
+
}
|
|
4601
|
+
function subsonicCoverUrl(account, coverArtId, size = 480) {
|
|
4602
|
+
return subsonicUrl(account, "getCoverArt", { id: coverArtId, size: String(size) }, false);
|
|
4603
|
+
}
|
|
4604
|
+
function parseSubsonicSong(account, entry) {
|
|
4605
|
+
const record = entry ?? {};
|
|
4606
|
+
const id = text3(record["id"]);
|
|
4607
|
+
const title = text3(record["title"]);
|
|
4608
|
+
if (!id || !title || record["isVideo"] === true) return null;
|
|
4609
|
+
const coverArt = text3(record["coverArt"]);
|
|
4610
|
+
const item = {
|
|
4611
|
+
id: mediaSourceItemId(account.id, id),
|
|
4612
|
+
kind: "audio",
|
|
4613
|
+
title,
|
|
4614
|
+
url: subsonicStreamUrl(account, id)
|
|
4615
|
+
};
|
|
4616
|
+
const artist = text3(record["artist"]);
|
|
4617
|
+
if (artist) item.artist = artist;
|
|
4618
|
+
const album = text3(record["album"]);
|
|
4619
|
+
if (album) item.album = album;
|
|
4620
|
+
const genre = text3(record["genre"]);
|
|
4621
|
+
if (genre) item.genre = genre;
|
|
4622
|
+
const year = positiveInteger3(record["year"]);
|
|
4623
|
+
if (year) item.year = year;
|
|
4624
|
+
const trackNo = positiveInteger3(record["track"]);
|
|
4625
|
+
if (trackNo) item.trackNo = trackNo;
|
|
4626
|
+
const discNo = positiveInteger3(record["discNumber"]);
|
|
4627
|
+
if (discNo) item.discNo = discNo;
|
|
4628
|
+
const duration = positiveInteger3(record["duration"]);
|
|
4629
|
+
if (duration) item.durationSec = duration;
|
|
4630
|
+
if (coverArt) item.artworkUrl = subsonicCoverUrl(account, coverArt);
|
|
4631
|
+
const suffix = text3(record["suffix"]);
|
|
4632
|
+
if (suffix) item.container = suffix;
|
|
4633
|
+
return item;
|
|
4634
|
+
}
|
|
4635
|
+
function itemOrEmpty(account, song) {
|
|
4636
|
+
const item = parseSubsonicSong(account, song);
|
|
4637
|
+
return item ? [item] : [];
|
|
4638
|
+
}
|
|
4639
|
+
function subsonicUrl(account, method, parameters, json) {
|
|
4640
|
+
const [salt = "", token = ""] = (account.secret ?? "").split(":");
|
|
4641
|
+
const query = new URLSearchParams({
|
|
4642
|
+
...parameters,
|
|
4643
|
+
u: account.username ?? "",
|
|
4644
|
+
t: token,
|
|
4645
|
+
s: salt,
|
|
4646
|
+
v: SUBSONIC_API_VERSION,
|
|
4647
|
+
c: SUBSONIC_CLIENT,
|
|
4648
|
+
...json ? { f: "json" } : {}
|
|
4649
|
+
});
|
|
4650
|
+
return `${account.baseUrl}/rest/${method}.view?${query}`;
|
|
4651
|
+
}
|
|
4652
|
+
async function call(http, account, method, parameters, signal) {
|
|
4653
|
+
const response = await http.send({ url: subsonicUrl(account, method, parameters, true), ...signal ? { signal } : {} });
|
|
4654
|
+
if (response.status === 404) throw new Error("No Subsonic API at this address");
|
|
4655
|
+
if (response.status >= 400) throw new Error(`Subsonic request failed (HTTP ${response.status})`);
|
|
4656
|
+
const body = await response.json();
|
|
4657
|
+
const payload = body?.["subsonic-response"];
|
|
4658
|
+
if (!payload) throw new Error("No Subsonic API at this address");
|
|
4659
|
+
if (payload["status"] !== "ok") {
|
|
4660
|
+
const error = payload["error"] ?? {};
|
|
4661
|
+
const code = Number(error["code"]);
|
|
4662
|
+
if (code === 40) throw new Error("The server refused this user name or password");
|
|
4663
|
+
if (code === 41) throw new Error("This server does not accept signed requests");
|
|
4664
|
+
throw new Error(text3(error["message"]) ?? "Subsonic request failed");
|
|
4665
|
+
}
|
|
4666
|
+
return payload;
|
|
4667
|
+
}
|
|
4668
|
+
function randomSalt() {
|
|
4669
|
+
const bytes = new Uint8Array(8);
|
|
4670
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
4671
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4672
|
+
}
|
|
4673
|
+
function asArray(value) {
|
|
4674
|
+
if (Array.isArray(value)) return value;
|
|
4675
|
+
return value === void 0 || value === null ? [] : [value];
|
|
4676
|
+
}
|
|
4677
|
+
function text3(value) {
|
|
4678
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
4679
|
+
}
|
|
4680
|
+
function positiveInteger3(value) {
|
|
4681
|
+
const number = Number(value);
|
|
4682
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
4683
|
+
}
|
|
4684
|
+
function capitalize(value) {
|
|
4685
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
// src/online/webdav.ts
|
|
4689
|
+
var PROPFIND_BODY = '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/><d:getcontentlength/><d:getcontenttype/><d:getlastmodified/></d:prop></d:propfind>';
|
|
4690
|
+
function createWebDavConnector() {
|
|
4691
|
+
return {
|
|
4692
|
+
kind: "webdav",
|
|
4693
|
+
connect: (input, http) => connectWebDav(input, http),
|
|
4694
|
+
browse: (account, options, http) => browseWebDav(account, options, http),
|
|
4695
|
+
lyrics: (account, item, http) => readWebDavLyrics(account, item, http)
|
|
4696
|
+
};
|
|
4697
|
+
}
|
|
4698
|
+
async function connectWebDav(input, http) {
|
|
4699
|
+
const baseUrl = normalizeMediaSourceAddress(input.address);
|
|
4700
|
+
const username = input.username?.trim();
|
|
4701
|
+
const account = {
|
|
4702
|
+
id: `webdav-${shortHash2(`${baseUrl}|${username ?? ""}`)}`,
|
|
4703
|
+
kind: "webdav",
|
|
4704
|
+
name: input.name?.trim() || decodeURIComponent(new URL(baseUrl).pathname.split("/").filter(Boolean).pop() ?? "") || new URL(baseUrl).host,
|
|
4705
|
+
baseUrl,
|
|
4706
|
+
...username ? { username, secret: basicCredentials(username, input.password ?? "") } : {}
|
|
4707
|
+
};
|
|
4708
|
+
const entries = await propfind(http, account, `${baseUrl}/`, "0");
|
|
4709
|
+
if (!entries[0]?.collection) throw new Error("This address is a file, not a WebDAV folder");
|
|
4710
|
+
return account;
|
|
4711
|
+
}
|
|
4712
|
+
async function browseWebDav(account, options, http) {
|
|
4713
|
+
const kind = options.kind ?? "audio";
|
|
4714
|
+
const basePath = new URL(account.baseUrl).pathname.replace(/\/+$/, "");
|
|
4715
|
+
const folderPath = options.containerId ?? basePath;
|
|
4716
|
+
if (!isInside(basePath, folderPath)) throw new Error("This folder is outside the WebDAV share");
|
|
4717
|
+
const folderUrl = `${new URL(account.baseUrl).origin}${encodePath(folderPath)}/`;
|
|
4718
|
+
const entries = await propfind(http, account, folderUrl, "1", options.signal);
|
|
4719
|
+
const current = trimSlash(folderPath) || "/";
|
|
4720
|
+
const children = entries.filter((entry) => (trimSlash(entry.path) || "/") !== current && isInside(basePath, entry.path));
|
|
4721
|
+
const containers = children.filter((entry) => entry.collection && !isIgnoredDirectoryName(entry.name)).sort((left, right) => left.name.localeCompare(right.name, void 0, { numeric: true, sensitivity: "base" })).map((entry) => ({ id: trimSlash(entry.path), title: entry.name, kind: "mixed" }));
|
|
4722
|
+
const items = children.filter((entry) => !entry.collection && isMediaFileName(entry.name, kind)).sort((left, right) => left.name.localeCompare(right.name, void 0, { numeric: true, sensitivity: "base" })).map((entry) => webDavItem(account, entry, basePath, kind));
|
|
4723
|
+
return { containers, items, total: items.length };
|
|
4724
|
+
}
|
|
4725
|
+
function parseWebDavMultistatus(xml) {
|
|
4726
|
+
const responses = [...xml.matchAll(/<(?:[\w.-]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?response>/gi)];
|
|
4727
|
+
return responses.flatMap((match) => {
|
|
4728
|
+
const block = match[1];
|
|
4729
|
+
const href = xmlTagText(block, "href");
|
|
4730
|
+
if (!href) return [];
|
|
4731
|
+
const okProps = [...block.matchAll(/<(?:[\w.-]+:)?propstat\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?propstat>/gi)].map((propstat) => propstat[1]).filter((propstat) => !/<(?:[\w.-]+:)?status\b[^>]*>[^<]*\b(?:4\d\d|5\d\d)\b/i.test(propstat)).join("");
|
|
4732
|
+
const props = okProps || block;
|
|
4733
|
+
let path;
|
|
4734
|
+
try {
|
|
4735
|
+
path = decodeURIComponent(new URL(decodeXmlEntities(href), "http://webdav.invalid").pathname);
|
|
4736
|
+
} catch {
|
|
4737
|
+
return [];
|
|
4738
|
+
}
|
|
4739
|
+
const collection = /<(?:[\w.-]+:)?collection\b/i.test(xmlTagContent(props, "resourcetype") ?? "");
|
|
4740
|
+
const name = xmlTagText(props, "displayname") || path.replace(/\/+$/, "").split("/").pop() || "";
|
|
4741
|
+
const size = Number(xmlTagText(props, "getcontentlength"));
|
|
4742
|
+
const modified = Date.parse(xmlTagText(props, "getlastmodified") ?? "");
|
|
4743
|
+
const contentType = xmlTagText(props, "getcontenttype");
|
|
4744
|
+
return [{
|
|
4745
|
+
path,
|
|
4746
|
+
name,
|
|
4747
|
+
collection,
|
|
4748
|
+
...Number.isFinite(size) && size > 0 ? { size } : {},
|
|
4749
|
+
...contentType ? { contentType } : {},
|
|
4750
|
+
...Number.isFinite(modified) ? { lastModified: modified } : {}
|
|
4751
|
+
}];
|
|
4752
|
+
});
|
|
4753
|
+
}
|
|
4754
|
+
function webDavAuthorization(account) {
|
|
4755
|
+
return account.secret?.startsWith("basic:") ? `Basic ${account.secret.slice(6)}` : void 0;
|
|
4756
|
+
}
|
|
4757
|
+
var MAX_LYRICS_BYTES = 256 * 1024;
|
|
4758
|
+
async function readWebDavLyrics(account, item, http) {
|
|
4759
|
+
const extension = item.url.lastIndexOf(".");
|
|
4760
|
+
if (extension < 0) return null;
|
|
4761
|
+
const authorization = webDavAuthorization(account);
|
|
4762
|
+
const response = await http.send({
|
|
4763
|
+
url: `${item.url.slice(0, extension)}.lrc`,
|
|
4764
|
+
headers: authorization ? { Authorization: authorization } : {}
|
|
4765
|
+
});
|
|
4766
|
+
if (response.status >= 400) return null;
|
|
4767
|
+
const text6 = await response.text();
|
|
4768
|
+
return text6.trim() && text6.length <= MAX_LYRICS_BYTES ? text6 : null;
|
|
4769
|
+
}
|
|
4770
|
+
function webDavItem(account, entry, basePath, kind) {
|
|
4771
|
+
const relativePath = trimSlash(entry.path).slice(basePath.length).replace(/^\/+/, "");
|
|
4772
|
+
const item = {
|
|
4773
|
+
id: mediaSourceItemId(account.id, trimSlash(entry.path)),
|
|
4774
|
+
kind,
|
|
4775
|
+
title: entry.name,
|
|
4776
|
+
url: `${new URL(account.baseUrl).origin}${encodePath(entry.path)}`
|
|
4777
|
+
};
|
|
4778
|
+
if (kind === "audio") {
|
|
4779
|
+
const fields = audioFieldsFromTags({ relativePath });
|
|
4780
|
+
item.title = fields.title;
|
|
4781
|
+
if (fields.artist) item.artist = fields.artist;
|
|
4782
|
+
if (fields.albumArtist) item.albumArtist = fields.albumArtist;
|
|
4783
|
+
if (fields.album) item.album = fields.album;
|
|
4784
|
+
if (fields.trackNo) item.trackNo = fields.trackNo;
|
|
4785
|
+
} else {
|
|
4786
|
+
const fields = videoFieldsFromTags({ relativePath });
|
|
4787
|
+
item.title = fields.title;
|
|
4788
|
+
if (fields.series) item.series = fields.series;
|
|
4789
|
+
if (fields.season) item.season = fields.season;
|
|
4790
|
+
if (fields.episode) item.episode = fields.episode;
|
|
4791
|
+
if (fields.year) item.year = fields.year;
|
|
4792
|
+
}
|
|
4793
|
+
return item;
|
|
4794
|
+
}
|
|
4795
|
+
async function propfind(http, account, url, depth, signal) {
|
|
4796
|
+
const authorization = webDavAuthorization(account);
|
|
4797
|
+
const response = await http.send({
|
|
4798
|
+
url,
|
|
4799
|
+
method: "PROPFIND",
|
|
4800
|
+
headers: {
|
|
4801
|
+
Depth: depth,
|
|
4802
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
4803
|
+
...authorization ? { Authorization: authorization } : {}
|
|
4804
|
+
},
|
|
4805
|
+
body: PROPFIND_BODY,
|
|
4806
|
+
...signal ? { signal } : {}
|
|
4807
|
+
});
|
|
4808
|
+
if (response.status === 401 || response.status === 403) throw new Error("The WebDAV server refused this user name or password");
|
|
4809
|
+
if (response.status === 404) throw new Error("No WebDAV folder at this address");
|
|
4810
|
+
if (response.status === 405 || response.status === 501) throw new Error("This address does not speak WebDAV");
|
|
4811
|
+
if (response.status !== 207 && response.status >= 300) throw new Error(`WebDAV request failed (HTTP ${response.status})`);
|
|
4812
|
+
return parseWebDavMultistatus(await response.text());
|
|
4813
|
+
}
|
|
4814
|
+
function encodePath(path) {
|
|
4815
|
+
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
4816
|
+
}
|
|
4817
|
+
function trimSlash(path) {
|
|
4818
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
4819
|
+
}
|
|
4820
|
+
function isInside(basePath, path) {
|
|
4821
|
+
const base = trimSlash(basePath) || "/";
|
|
4822
|
+
const target = trimSlash(path);
|
|
4823
|
+
if (target.split("/").includes("..")) return false;
|
|
4824
|
+
return base === "/" || target === base || target.startsWith(`${base}/`);
|
|
4825
|
+
}
|
|
4826
|
+
function basicCredentials(username, password) {
|
|
4827
|
+
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
|
4828
|
+
let binary = "";
|
|
4829
|
+
bytes.forEach((byte) => {
|
|
4830
|
+
binary += String.fromCharCode(byte);
|
|
4831
|
+
});
|
|
4832
|
+
return `basic:${btoa(binary)}`;
|
|
4833
|
+
}
|
|
4834
|
+
function shortHash2(value) {
|
|
4835
|
+
let hash = 0;
|
|
4836
|
+
for (let index = 0; index < value.length; index += 1) hash = Math.imul(31, hash) + value.charCodeAt(index) | 0;
|
|
4837
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
// src/online/internet-archive.ts
|
|
4841
|
+
var INTERNET_ARCHIVE_ORIGIN = "https://archive.org";
|
|
4842
|
+
var INTERNET_ARCHIVE_PLAYABLE_FILTERS = [
|
|
4843
|
+
"-collection:(stream_only)",
|
|
4844
|
+
"-access-restricted-item:(true)"
|
|
4845
|
+
];
|
|
4846
|
+
var SEARCH_FIELDS = ["identifier", "title", "creator", "year", "licenseurl"];
|
|
4847
|
+
var VIDEO_FORMATS = ["h.264", "MPEG4", "h.264 HD", "512Kb MPEG4", "h.264 IA", "WebM", "Ogg Video"];
|
|
4848
|
+
var AUDIO_FORMATS = ["VBR MP3", "MP3", "128Kbps MP3", "64Kbps MP3", "Ogg Vorbis"];
|
|
4849
|
+
var LUCENE_SPECIAL = /[+\-&|!(){}[\]^"~*?:\\/]/g;
|
|
4850
|
+
function buildInternetArchiveSearchUrl(options) {
|
|
4851
|
+
const clauses = [`mediatype:(${options.mediaType})`, ...options.filters ?? []];
|
|
4852
|
+
const terms = (options.text ?? "").replace(LUCENE_SPECIAL, " ").trim().split(/\s+/).filter(Boolean);
|
|
4853
|
+
if (terms.length) {
|
|
4854
|
+
const words = terms.join(" AND ");
|
|
4855
|
+
clauses.push(`(title:(${words}) OR creator:(${words}) OR subject:(${words}))`);
|
|
4856
|
+
}
|
|
4857
|
+
const params = new URLSearchParams({ q: clauses.join(" AND ") });
|
|
4858
|
+
for (const field of SEARCH_FIELDS) params.append("fl[]", field);
|
|
4859
|
+
params.append("sort[]", options.sort ?? "downloads desc");
|
|
4860
|
+
params.set("rows", String(clampInteger(options.rows, 1, 100, 20)));
|
|
4861
|
+
params.set("page", String(clampInteger(options.page, 1, 1e4, 1)));
|
|
4862
|
+
params.set("output", "json");
|
|
4863
|
+
return `${originOf(options.origin)}/advancedsearch.php?${params}`;
|
|
4864
|
+
}
|
|
4865
|
+
async function searchInternetArchive(options, request2 = {}) {
|
|
4866
|
+
const origin = request2.origin ?? options.origin;
|
|
4867
|
+
const body = await getJson(buildInternetArchiveSearchUrl({ ...options, origin }), request2);
|
|
4868
|
+
const response = body?.response;
|
|
4869
|
+
const docs = Array.isArray(response?.docs) ? response.docs : [];
|
|
4870
|
+
const items = docs.flatMap((doc) => {
|
|
4871
|
+
const record = doc;
|
|
4872
|
+
const identifier = firstText(record["identifier"]);
|
|
4873
|
+
if (!identifier) return [];
|
|
4874
|
+
return [{
|
|
4875
|
+
identifier,
|
|
4876
|
+
title: firstText(record["title"]) ?? identifier,
|
|
4877
|
+
creator: firstText(record["creator"]),
|
|
4878
|
+
year: parseYear(record["year"]),
|
|
4879
|
+
licenseUrl: firstText(record["licenseurl"]),
|
|
4880
|
+
thumbnailUrl: internetArchiveThumbnailUrl(identifier, origin),
|
|
4881
|
+
detailsUrl: internetArchiveDetailsUrl(identifier, origin)
|
|
4882
|
+
}];
|
|
4883
|
+
});
|
|
4884
|
+
return {
|
|
4885
|
+
total: typeof response?.numFound === "number" ? response.numFound : items.length,
|
|
4886
|
+
page: clampInteger(options.page, 1, 1e4, 1),
|
|
4887
|
+
items
|
|
4888
|
+
};
|
|
4889
|
+
}
|
|
4890
|
+
async function fetchInternetArchiveItem(identifier, request2 = {}) {
|
|
4891
|
+
const body = await getJson(`${originOf(request2.origin)}/metadata/${encodeURIComponent(identifier)}`, request2);
|
|
4892
|
+
return parseInternetArchiveItem(identifier, body, request2.origin);
|
|
4893
|
+
}
|
|
4894
|
+
function parseInternetArchiveItem(identifier, body, origin) {
|
|
4895
|
+
const record = body ?? {};
|
|
4896
|
+
if (!record.metadata) throw new Error(`Internet Archive item not found: ${identifier}`);
|
|
4897
|
+
const metadata = record.metadata;
|
|
4898
|
+
const collections = allTexts2(metadata["collection"]);
|
|
4899
|
+
const files = (Array.isArray(record.files) ? record.files : []).flatMap((file) => {
|
|
4900
|
+
const entry = file;
|
|
4901
|
+
const name = firstText(entry["name"]);
|
|
4902
|
+
if (!name || firstText(entry["private"]) === "true") return [];
|
|
4903
|
+
return [{
|
|
4904
|
+
name,
|
|
4905
|
+
format: firstText(entry["format"]),
|
|
4906
|
+
source: firstText(entry["source"]),
|
|
4907
|
+
original: firstText(entry["original"]),
|
|
4908
|
+
title: firstText(entry["title"]),
|
|
4909
|
+
creator: firstText(entry["creator"]) ?? firstText(entry["artist"]),
|
|
4910
|
+
album: firstText(entry["album"]),
|
|
4911
|
+
track: firstText(entry["track"]),
|
|
4912
|
+
length: firstText(entry["length"]),
|
|
4913
|
+
size: firstText(entry["size"])
|
|
4914
|
+
}];
|
|
4915
|
+
});
|
|
4916
|
+
return {
|
|
4917
|
+
identifier,
|
|
4918
|
+
title: firstText(metadata["title"]) ?? identifier,
|
|
4919
|
+
creator: allTexts2(metadata["creator"]).join(", ") || void 0,
|
|
4920
|
+
year: parseYear(metadata["year"]) ?? parseYear(metadata["date"]),
|
|
4921
|
+
description: plainDescription(firstText(metadata["description"])),
|
|
4922
|
+
licenseUrl: firstText(metadata["licenseurl"]),
|
|
4923
|
+
restricted: record.is_dark === true || firstText(metadata["access-restricted-item"]) === "true" || collections.includes("stream_only"),
|
|
4924
|
+
thumbnailUrl: internetArchiveThumbnailUrl(identifier, origin),
|
|
4925
|
+
detailsUrl: internetArchiveDetailsUrl(identifier, origin),
|
|
4926
|
+
storageUrls: storageUrlsOf(record),
|
|
4927
|
+
files
|
|
4928
|
+
};
|
|
4929
|
+
}
|
|
4930
|
+
var STORAGE_HOST = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.archive\.org$/i;
|
|
4931
|
+
function storageUrlsOf(record) {
|
|
4932
|
+
const dir = firstText(record.dir);
|
|
4933
|
+
if (!dir || !dir.startsWith("/") || dir.includes("..")) return [];
|
|
4934
|
+
const hosts = [...allTexts2(record.workable_servers), ...allTexts2(record.d1), ...allTexts2(record.d2), ...allTexts2(record.server)];
|
|
4935
|
+
return [...new Set(hosts.filter((host) => STORAGE_HOST.test(host)))].map((host) => `https://${host}${dir}`);
|
|
4936
|
+
}
|
|
4937
|
+
function storageFileUrls(item, fileName) {
|
|
4938
|
+
const path = fileName.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
4939
|
+
return item.storageUrls.map((base) => `${base}/${path}`);
|
|
4940
|
+
}
|
|
4941
|
+
function selectInternetArchiveVideo(item, origin) {
|
|
4942
|
+
if (item.restricted) return null;
|
|
4943
|
+
for (const format of VIDEO_FORMATS) {
|
|
4944
|
+
const file = item.files.find((candidate) => candidate.format === format);
|
|
4945
|
+
if (file) {
|
|
4946
|
+
return {
|
|
4947
|
+
fileName: file.name,
|
|
4948
|
+
format,
|
|
4949
|
+
url: internetArchiveDownloadUrl(item.identifier, file.name, origin),
|
|
4950
|
+
storageUrls: storageFileUrls(item, file.name),
|
|
4951
|
+
durationSec: parseInternetArchiveLength(file.length)
|
|
4952
|
+
};
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
return null;
|
|
4956
|
+
}
|
|
4957
|
+
function internetArchiveAudioTracks(item, origin) {
|
|
4958
|
+
if (item.restricted) return [];
|
|
4959
|
+
const format = AUDIO_FORMATS.find((candidate) => item.files.some((file) => file.format === candidate));
|
|
4960
|
+
if (!format) return [];
|
|
4961
|
+
const candidates = item.files.filter((file) => file.format === format);
|
|
4962
|
+
const names = new Set(candidates.map((file) => file.name));
|
|
4963
|
+
return candidates.filter((file) => !file.original || !names.has(file.original)).map((file) => ({
|
|
4964
|
+
id: `${item.identifier}/${file.name}`,
|
|
4965
|
+
identifier: item.identifier,
|
|
4966
|
+
fileName: file.name,
|
|
4967
|
+
title: readableTitle(file.title ?? fileStem2(file.name)),
|
|
4968
|
+
artist: file.creator ?? item.creator,
|
|
4969
|
+
album: file.album ?? item.title,
|
|
4970
|
+
trackNo: parseTrackNumber(file.track),
|
|
4971
|
+
durationSec: parseInternetArchiveLength(file.length),
|
|
4972
|
+
url: internetArchiveDownloadUrl(item.identifier, file.name, origin),
|
|
4973
|
+
storageUrls: storageFileUrls(item, file.name),
|
|
4974
|
+
artworkUrl: item.thumbnailUrl
|
|
4975
|
+
})).sort((left, right) => (left.trackNo ?? Number.MAX_SAFE_INTEGER) - (right.trackNo ?? Number.MAX_SAFE_INTEGER) || left.fileName.localeCompare(right.fileName, void 0, { numeric: true }));
|
|
4976
|
+
}
|
|
4977
|
+
function internetArchiveDownloadUrl(identifier, fileName, origin) {
|
|
4978
|
+
const path = fileName.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
4979
|
+
return `${originOf(origin)}/download/${encodeURIComponent(identifier)}/${path}`;
|
|
4980
|
+
}
|
|
4981
|
+
function internetArchiveThumbnailUrl(identifier, origin) {
|
|
4982
|
+
return `${originOf(origin)}/services/img/${encodeURIComponent(identifier)}`;
|
|
4983
|
+
}
|
|
4984
|
+
function internetArchiveDetailsUrl(identifier, origin) {
|
|
4985
|
+
return `${originOf(origin)}/details/${encodeURIComponent(identifier)}`;
|
|
4986
|
+
}
|
|
4987
|
+
function parseInternetArchiveLength(length) {
|
|
4988
|
+
if (!length) return void 0;
|
|
4989
|
+
const parts = length.trim().split(":");
|
|
4990
|
+
if (parts.some((part) => !/^\d+(?:\.\d+)?$/.test(part))) return void 0;
|
|
4991
|
+
const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
|
|
4992
|
+
return seconds > 0 ? Math.round(seconds) : void 0;
|
|
4993
|
+
}
|
|
4994
|
+
async function getJson(url, request2) {
|
|
4995
|
+
const http = request2.http ?? createFetchOnlineJsonHttp();
|
|
4996
|
+
try {
|
|
4997
|
+
return await http.getJson(url, { signal: request2.signal });
|
|
4998
|
+
} catch (error) {
|
|
4999
|
+
if (request2.signal?.aborted) throw error;
|
|
5000
|
+
throw new Error(`Internet Archive request failed (${error instanceof Error ? error.message : String(error)})`);
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
function originOf(origin) {
|
|
5004
|
+
return (origin ?? INTERNET_ARCHIVE_ORIGIN).replace(/\/+$/, "");
|
|
5005
|
+
}
|
|
5006
|
+
function firstText(value) {
|
|
5007
|
+
return allTexts2(value)[0];
|
|
5008
|
+
}
|
|
5009
|
+
function allTexts2(value) {
|
|
5010
|
+
const values = Array.isArray(value) ? value : [value];
|
|
5011
|
+
return values.flatMap((entry) => {
|
|
5012
|
+
if (typeof entry === "number") return [String(entry)];
|
|
5013
|
+
return typeof entry === "string" && entry.trim() ? [entry.trim()] : [];
|
|
5014
|
+
});
|
|
5015
|
+
}
|
|
5016
|
+
function parseYear(value) {
|
|
5017
|
+
const match = /\b(1[89]\d{2}|20\d{2})\b/.exec(firstText(value) ?? "");
|
|
5018
|
+
return match ? Number(match[1]) : void 0;
|
|
5019
|
+
}
|
|
5020
|
+
function parseTrackNumber(track) {
|
|
5021
|
+
const match = /^\s*(\d+)/.exec(track ?? "");
|
|
5022
|
+
return match ? Number(match[1]) : void 0;
|
|
5023
|
+
}
|
|
5024
|
+
function plainDescription(description) {
|
|
5025
|
+
if (!description) return void 0;
|
|
5026
|
+
const text6 = description.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim();
|
|
5027
|
+
if (!text6) return void 0;
|
|
5028
|
+
return text6.length > 320 ? `${text6.slice(0, 317).trimEnd()}...` : text6;
|
|
5029
|
+
}
|
|
5030
|
+
function fileStem2(name) {
|
|
5031
|
+
const base = name.split("/").pop() ?? name;
|
|
5032
|
+
return base.replace(/\.[^.]+$/, "") || base;
|
|
5033
|
+
}
|
|
5034
|
+
function readableTitle(title) {
|
|
5035
|
+
if (/\s/.test(title) && !title.includes("_")) return title;
|
|
5036
|
+
return title.replace(/^\d{1,3}\s*[.\-_)]\s*/, "").replace(/_+/g, " ").replace(/\s+/g, " ").trim() || title;
|
|
5037
|
+
}
|
|
5038
|
+
function clampInteger(value, min, max, fallback) {
|
|
5039
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
5040
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
// src/online/radio-browser.ts
|
|
5044
|
+
var RADIO_BROWSER_ORIGIN = "https://de1.api.radio-browser.info";
|
|
5045
|
+
function buildRadioBrowserSearchUrl(options = {}) {
|
|
5046
|
+
const params = new URLSearchParams();
|
|
5047
|
+
if (options.countryCode?.trim()) params.set("countrycode", options.countryCode.trim().toUpperCase());
|
|
5048
|
+
if (options.name?.trim()) params.set("name", options.name.trim());
|
|
5049
|
+
if (options.tag?.trim()) params.set("tag", options.tag.trim());
|
|
5050
|
+
params.set("hidebroken", String(options.hideBroken ?? true));
|
|
5051
|
+
params.set("limit", String(clampInteger2(options.limit, 1, 500, 20)));
|
|
5052
|
+
if (options.offset) params.set("offset", String(clampInteger2(options.offset, 0, 1e5, 0)));
|
|
5053
|
+
const order = options.order ?? "clickcount";
|
|
5054
|
+
params.set("order", order);
|
|
5055
|
+
if (order !== "name") params.set("reverse", "true");
|
|
5056
|
+
return `${originOf2(options.origin)}/json/stations/search?${params}`;
|
|
5057
|
+
}
|
|
5058
|
+
async function searchRadioStations(options = {}, request2 = {}) {
|
|
5059
|
+
const url = buildRadioBrowserSearchUrl({ ...options, origin: request2.origin ?? options.origin });
|
|
5060
|
+
const http = request2.http ?? createFetchOnlineJsonHttp();
|
|
5061
|
+
let body;
|
|
5062
|
+
try {
|
|
5063
|
+
body = await http.getJson(url, { signal: request2.signal });
|
|
5064
|
+
} catch (error) {
|
|
5065
|
+
if (request2.signal?.aborted) throw error;
|
|
5066
|
+
throw new Error(`Radio Browser request failed (${error instanceof Error ? error.message : String(error)})`);
|
|
5067
|
+
}
|
|
5068
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5069
|
+
return (Array.isArray(body) ? body : []).flatMap((entry) => {
|
|
5070
|
+
const station = parseRadioStation(entry);
|
|
5071
|
+
if (!station || seen.has(station.id)) return [];
|
|
5072
|
+
seen.add(station.id);
|
|
5073
|
+
return [station];
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
function parseRadioStation(entry) {
|
|
5077
|
+
const record = entry ?? {};
|
|
5078
|
+
const id = text4(record["stationuuid"]);
|
|
5079
|
+
const streamUrl = httpUrl(text4(record["url_resolved"])) ?? httpUrl(text4(record["url"]));
|
|
5080
|
+
if (!id || !streamUrl) return null;
|
|
5081
|
+
const bitrate = Number(record["bitrate"]);
|
|
5082
|
+
return {
|
|
5083
|
+
id,
|
|
5084
|
+
name: text4(record["name"])?.replace(/\s+/g, " ") ?? streamUrl,
|
|
5085
|
+
streamUrl,
|
|
5086
|
+
homepageUrl: httpUrl(text4(record["homepage"])),
|
|
5087
|
+
faviconUrl: httpUrl(text4(record["favicon"])),
|
|
5088
|
+
country: text4(record["country"]),
|
|
5089
|
+
countryCode: text4(record["countrycode"]),
|
|
5090
|
+
language: text4(record["language"]),
|
|
5091
|
+
tags: (text4(record["tags"]) ?? "").split(",").map((tag) => tag.trim()).filter(Boolean),
|
|
5092
|
+
codec: text4(record["codec"]),
|
|
5093
|
+
bitrateKbps: Number.isFinite(bitrate) && bitrate > 0 ? bitrate : void 0,
|
|
5094
|
+
hls: record["hls"] === 1 || record["hls"] === true || /\.m3u8(?:$|\?)/i.test(streamUrl),
|
|
5095
|
+
secure: streamUrl.startsWith("https://")
|
|
5096
|
+
};
|
|
5097
|
+
}
|
|
5098
|
+
function text4(value) {
|
|
5099
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
5100
|
+
}
|
|
5101
|
+
function httpUrl(value) {
|
|
5102
|
+
return value && /^https?:\/\//i.test(value) ? value : void 0;
|
|
5103
|
+
}
|
|
5104
|
+
function originOf2(origin) {
|
|
5105
|
+
return (origin ?? RADIO_BROWSER_ORIGIN).replace(/\/+$/, "");
|
|
5106
|
+
}
|
|
5107
|
+
function clampInteger2(value, min, max, fallback) {
|
|
5108
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
5109
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
// src/online/musicbrainz.ts
|
|
5113
|
+
var MUSICBRAINZ_ORIGIN = "https://musicbrainz.org";
|
|
5114
|
+
var LUCENE_SPECIAL2 = /[+\-&|!(){}[\]^"~*?:\\/]/g;
|
|
5115
|
+
function buildMusicBrainzRecordingSearchUrl(query, origin) {
|
|
5116
|
+
const clauses = [`recording:(${luceneWords(query.title)})`];
|
|
5117
|
+
if (query.artist && luceneWords(query.artist)) clauses.push(`artist:(${luceneWords(query.artist)})`);
|
|
5118
|
+
if (query.durationSec && query.durationSec > 0) {
|
|
5119
|
+
const tolerance = Math.max(1, query.durationToleranceSec ?? 4);
|
|
5120
|
+
const from = Math.max(0, Math.round((query.durationSec - tolerance) * 1e3));
|
|
5121
|
+
const to = Math.round((query.durationSec + tolerance) * 1e3);
|
|
5122
|
+
clauses.push(`dur:[${from} TO ${to}]`);
|
|
5123
|
+
}
|
|
5124
|
+
const params = new URLSearchParams({
|
|
5125
|
+
query: clauses.join(" AND "),
|
|
5126
|
+
fmt: "json",
|
|
5127
|
+
limit: String(Math.min(25, Math.max(1, Math.trunc(query.limit ?? 10))))
|
|
5128
|
+
});
|
|
5129
|
+
return `${(origin ?? MUSICBRAINZ_ORIGIN).replace(/\/+$/, "")}/ws/2/recording?${params}`;
|
|
5130
|
+
}
|
|
5131
|
+
async function searchMusicBrainzRecordings(query, request2 = {}) {
|
|
5132
|
+
if (!luceneWords(query.title)) return [];
|
|
5133
|
+
const http = request2.http ?? createFetchOnlineJsonHttp();
|
|
5134
|
+
let body;
|
|
5135
|
+
try {
|
|
5136
|
+
body = await http.getJson(buildMusicBrainzRecordingSearchUrl(query, request2.origin), { signal: request2.signal });
|
|
5137
|
+
} catch (error) {
|
|
5138
|
+
if (request2.signal?.aborted) throw error;
|
|
5139
|
+
throw new Error(`MusicBrainz request failed (${error instanceof Error ? error.message : String(error)})`);
|
|
5140
|
+
}
|
|
5141
|
+
const recordings = body?.recordings;
|
|
5142
|
+
return (Array.isArray(recordings) ? recordings : []).flatMap((entry) => {
|
|
5143
|
+
const recording = parseMusicBrainzRecording(entry);
|
|
5144
|
+
return recording ? [recording] : [];
|
|
5145
|
+
});
|
|
5146
|
+
}
|
|
5147
|
+
async function lookupMusicBrainzRecording(id, request2 = {}) {
|
|
5148
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) return null;
|
|
5149
|
+
const http = request2.http ?? createFetchOnlineJsonHttp();
|
|
5150
|
+
const params = new URLSearchParams({ inc: "releases release-groups artist-credits media", fmt: "json" });
|
|
5151
|
+
try {
|
|
5152
|
+
const body = await http.getJson(`${(request2.origin ?? MUSICBRAINZ_ORIGIN).replace(/\/+$/, "")}/ws/2/recording/${id}?${params}`, { signal: request2.signal });
|
|
5153
|
+
return parseMusicBrainzRecording({ score: 100, ...body });
|
|
5154
|
+
} catch (error) {
|
|
5155
|
+
if (request2.signal?.aborted) throw error;
|
|
5156
|
+
throw new Error(`MusicBrainz request failed (${error instanceof Error ? error.message : String(error)})`);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
function parseMusicBrainzRecording(entry) {
|
|
5160
|
+
const record = entry ?? {};
|
|
5161
|
+
const id = text5(record["id"]);
|
|
5162
|
+
const title = text5(record["title"]);
|
|
5163
|
+
if (!id || !title) return null;
|
|
5164
|
+
const length = Number(record["length"]);
|
|
5165
|
+
const releases = Array.isArray(record["releases"]) ? record["releases"] : [];
|
|
5166
|
+
return {
|
|
5167
|
+
id,
|
|
5168
|
+
score: Number(record["score"]) || 0,
|
|
5169
|
+
title,
|
|
5170
|
+
artist: artistCredit(record["artist-credit"]),
|
|
5171
|
+
durationSec: Number.isFinite(length) && length > 0 ? Math.round(length / 1e3) : void 0,
|
|
5172
|
+
releases: releases.flatMap((release) => {
|
|
5173
|
+
const item = release ?? {};
|
|
5174
|
+
const releaseId = text5(item["id"]);
|
|
5175
|
+
const releaseTitle = text5(item["title"]);
|
|
5176
|
+
if (!releaseId || !releaseTitle) return [];
|
|
5177
|
+
const medium = Array.isArray(item["media"]) ? item["media"][0] : void 0;
|
|
5178
|
+
const track = Array.isArray(medium?.["track"]) ? medium["track"][0] : void 0;
|
|
5179
|
+
const year = /^(\d{4})/.exec(text5(item["date"]) ?? "");
|
|
5180
|
+
return [{
|
|
5181
|
+
id: releaseId,
|
|
5182
|
+
title: releaseTitle,
|
|
5183
|
+
artist: artistCredit(item["artist-credit"]),
|
|
5184
|
+
status: text5(item["status"]),
|
|
5185
|
+
primaryType: text5(item["release-group"]?.["primary-type"]),
|
|
5186
|
+
year: year ? Number(year[1]) : void 0,
|
|
5187
|
+
discNo: positiveInteger4(medium?.["position"]),
|
|
5188
|
+
// Search results list the matching track; lookups only give its offset in the medium.
|
|
5189
|
+
trackNo: positiveInteger4(track?.["number"]) ?? offsetTrackNumber(medium?.["track-offset"])
|
|
5190
|
+
}];
|
|
5191
|
+
})
|
|
5192
|
+
};
|
|
5193
|
+
}
|
|
5194
|
+
function artistCredit(value) {
|
|
5195
|
+
if (!Array.isArray(value)) return void 0;
|
|
5196
|
+
const credit = value.map((part) => {
|
|
5197
|
+
const item = part ?? {};
|
|
5198
|
+
return `${text5(item["name"]) ?? ""}${typeof item["joinphrase"] === "string" ? item["joinphrase"] : ""}`;
|
|
5199
|
+
}).join("").trim();
|
|
5200
|
+
return credit || void 0;
|
|
5201
|
+
}
|
|
5202
|
+
function luceneWords(value) {
|
|
5203
|
+
return value.replace(LUCENE_SPECIAL2, " ").replace(/\s+/g, " ").trim();
|
|
5204
|
+
}
|
|
5205
|
+
function text5(value) {
|
|
5206
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
5207
|
+
}
|
|
5208
|
+
function offsetTrackNumber(offset) {
|
|
5209
|
+
const number = Number(offset);
|
|
5210
|
+
return Number.isInteger(number) && number >= 0 ? number + 1 : void 0;
|
|
5211
|
+
}
|
|
5212
|
+
function positiveInteger4(value) {
|
|
5213
|
+
const number = Number(value);
|
|
5214
|
+
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
// src/online/lyrics.ts
|
|
5218
|
+
var LRCLIB_ORIGIN = "https://lrclib.net";
|
|
5219
|
+
function parseLrc(text6, source = "file") {
|
|
5220
|
+
const raw = String(text6 ?? "").replace(/\r\n?/g, "\n");
|
|
5221
|
+
if (!raw.trim()) return null;
|
|
5222
|
+
const lines = [];
|
|
5223
|
+
const plain = [];
|
|
5224
|
+
let offsetSec = 0;
|
|
5225
|
+
let title;
|
|
5226
|
+
let artist;
|
|
5227
|
+
for (const line of raw.split("\n")) {
|
|
5228
|
+
const tag = /^\s*\[(ti|ar|al|offset)\s*:\s*([^\]]*)\]\s*$/i.exec(line);
|
|
5229
|
+
if (tag) {
|
|
5230
|
+
const value = tag[2].trim();
|
|
5231
|
+
if (/^ti$/i.test(tag[1])) title = value || void 0;
|
|
5232
|
+
if (/^ar$/i.test(tag[1])) artist = value || void 0;
|
|
5233
|
+
if (/^offset$/i.test(tag[1])) offsetSec = (Number(value) || 0) / 1e3;
|
|
5234
|
+
continue;
|
|
5235
|
+
}
|
|
5236
|
+
const stamps = [...line.matchAll(/\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
|
|
5237
|
+
const content = line.replace(/\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, "").trim();
|
|
5238
|
+
if (!stamps.length) {
|
|
5239
|
+
if (content) plain.push(content);
|
|
5240
|
+
continue;
|
|
5241
|
+
}
|
|
5242
|
+
for (const stamp of stamps) {
|
|
5243
|
+
const fraction = stamp[3] ? Number(`0.${stamp[3]}`) : 0;
|
|
5244
|
+
lines.push({ timeSec: Number(stamp[1]) * 60 + Number(stamp[2]) + fraction, text: content });
|
|
5245
|
+
}
|
|
5246
|
+
if (content) plain.push(content);
|
|
5247
|
+
}
|
|
5248
|
+
if (!lines.length && !plain.length) return null;
|
|
5249
|
+
const timed = lines.map((line) => ({ ...line, timeSec: Math.max(0, line.timeSec - offsetSec) })).sort((left, right) => left.timeSec - right.timeSec);
|
|
5250
|
+
return {
|
|
5251
|
+
source,
|
|
5252
|
+
...timed.length ? { lines: timed } : {},
|
|
5253
|
+
plain: plain.join("\n"),
|
|
5254
|
+
...title ? { title } : {},
|
|
5255
|
+
...artist ? { artist } : {}
|
|
5256
|
+
};
|
|
5257
|
+
}
|
|
5258
|
+
function lyricsFromTags(tags) {
|
|
5259
|
+
const entries = Array.isArray(tags) ? [...tags] : tags ? [tags] : [];
|
|
5260
|
+
for (const entry of entries) {
|
|
5261
|
+
const timed = (entry?.syncText ?? []).filter((line) => typeof line?.timestamp === "number");
|
|
5262
|
+
if (timed.length) {
|
|
5263
|
+
return {
|
|
5264
|
+
source: "tags",
|
|
5265
|
+
// music-metadata reports synchronised lyrics in milliseconds.
|
|
5266
|
+
lines: timed.map((line) => ({ timeSec: Math.max(0, line.timestamp / 1e3), text: (line.text ?? "").trim() })),
|
|
5267
|
+
plain: timed.map((line) => (line.text ?? "").trim()).filter(Boolean).join("\n")
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
for (const entry of entries) {
|
|
5272
|
+
const parsed = entry?.text ? parseLrc(entry.text, "tags") : null;
|
|
5273
|
+
if (parsed) return parsed;
|
|
5274
|
+
}
|
|
5275
|
+
return null;
|
|
5276
|
+
}
|
|
5277
|
+
function lyricsLineIndexAt(lines, positionSec) {
|
|
5278
|
+
if (!lines?.length) return -1;
|
|
5279
|
+
let low = 0;
|
|
5280
|
+
let high = lines.length - 1;
|
|
5281
|
+
let found = -1;
|
|
5282
|
+
while (low <= high) {
|
|
5283
|
+
const middle = low + high >> 1;
|
|
5284
|
+
if (lines[middle].timeSec <= positionSec + 1e-3) {
|
|
5285
|
+
found = middle;
|
|
5286
|
+
low = middle + 1;
|
|
5287
|
+
} else {
|
|
5288
|
+
high = middle - 1;
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
return found;
|
|
5292
|
+
}
|
|
5293
|
+
function buildLrclibGetUrl(query, origin = LRCLIB_ORIGIN) {
|
|
5294
|
+
const parameters = new URLSearchParams({ track_name: query.title, artist_name: query.artist ?? "" });
|
|
5295
|
+
if (query.album) parameters.set("album_name", query.album);
|
|
5296
|
+
if (query.durationSec) parameters.set("duration", String(Math.round(query.durationSec)));
|
|
5297
|
+
return `${origin}/api/get?${parameters.toString()}`;
|
|
5298
|
+
}
|
|
5299
|
+
function buildLrclibSearchUrl(query, origin = LRCLIB_ORIGIN) {
|
|
5300
|
+
const parameters = new URLSearchParams({ track_name: query.title });
|
|
5301
|
+
if (query.artist) parameters.set("artist_name", query.artist);
|
|
5302
|
+
return `${origin}/api/search?${parameters.toString()}`;
|
|
5303
|
+
}
|
|
5304
|
+
async function fetchLrclibLyrics(query, options = {}) {
|
|
5305
|
+
const http = options.http ?? createFetchOnlineJsonHttp();
|
|
5306
|
+
const request2 = options.signal ? { signal: options.signal } : {};
|
|
5307
|
+
const title = query.title.trim();
|
|
5308
|
+
if (!title) return null;
|
|
5309
|
+
const exact = await http.getJson(buildLrclibGetUrl({ ...query, title }, options.origin), request2).catch(() => null);
|
|
5310
|
+
const direct = lyricsFromRecord(exact);
|
|
5311
|
+
if (direct) return direct;
|
|
5312
|
+
const results = await http.getJson(buildLrclibSearchUrl({ ...query, title }, options.origin), request2).catch(() => null);
|
|
5313
|
+
if (!Array.isArray(results)) return null;
|
|
5314
|
+
const best = bestLrclibMatch(results, { ...query, title });
|
|
5315
|
+
return best ? lyricsFromRecord(best) : null;
|
|
5316
|
+
}
|
|
5317
|
+
function bestLrclibMatch(records, query) {
|
|
5318
|
+
const usable = records.filter((record) => !record.instrumental && (record.syncedLyrics || record.plainLyrics));
|
|
5319
|
+
if (!usable.length) return null;
|
|
5320
|
+
const score = (record) => {
|
|
5321
|
+
const durationGap2 = query.durationSec && record.duration ? Math.abs(record.duration - query.durationSec) : void 0;
|
|
5322
|
+
return (durationGap2 !== void 0 && durationGap2 <= 5 ? 100 - durationGap2 * 4 : 0) + (record.syncedLyrics ? 40 : 0) + similarity(record.trackName, query.title) * 30 + similarity(record.artistName, query.artist) * 20;
|
|
5323
|
+
};
|
|
5324
|
+
return [...usable].sort((left, right) => score(right) - score(left))[0] ?? null;
|
|
5325
|
+
}
|
|
5326
|
+
function lyricsFromRecord(record) {
|
|
5327
|
+
if (!record || record.instrumental) return null;
|
|
5328
|
+
const parsed = record.syncedLyrics ? parseLrc(record.syncedLyrics, "lrclib") : null;
|
|
5329
|
+
const plain = record.plainLyrics?.trim();
|
|
5330
|
+
if (!parsed && !plain) return null;
|
|
5331
|
+
return {
|
|
5332
|
+
source: "lrclib",
|
|
5333
|
+
...parsed?.lines ? { lines: parsed.lines } : {},
|
|
5334
|
+
plain: plain || parsed?.plain || "",
|
|
5335
|
+
...record.trackName ? { title: record.trackName } : {},
|
|
5336
|
+
...record.artistName ? { artist: record.artistName } : {},
|
|
5337
|
+
...record.id ? { id: record.id } : {}
|
|
5338
|
+
};
|
|
5339
|
+
}
|
|
5340
|
+
function similarity(left, right) {
|
|
5341
|
+
const clean = (value) => (value ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
5342
|
+
const a = clean(left);
|
|
5343
|
+
const b = clean(right);
|
|
5344
|
+
if (!a || !b) return 0;
|
|
5345
|
+
if (a === b) return 1;
|
|
5346
|
+
const words = new Set(a.split(" "));
|
|
5347
|
+
const shared = b.split(" ").filter((word) => words.has(word)).length;
|
|
5348
|
+
return shared / Math.max(words.size, b.split(" ").length);
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
// src/online/acoustid.ts
|
|
5352
|
+
var ACOUSTID_ORIGIN = "https://api.acoustid.org";
|
|
5353
|
+
function buildAcoustIdLookupUrl(query, origin) {
|
|
5354
|
+
const params = new URLSearchParams({
|
|
5355
|
+
client: query.clientKey,
|
|
5356
|
+
meta: "recordings",
|
|
5357
|
+
duration: String(Math.max(1, Math.round(query.durationSec))),
|
|
5358
|
+
fingerprint: query.fingerprint,
|
|
5359
|
+
format: "json"
|
|
5360
|
+
});
|
|
5361
|
+
return `${(origin ?? ACOUSTID_ORIGIN).replace(/\/+$/, "")}/v2/lookup?${params}`;
|
|
5362
|
+
}
|
|
5363
|
+
async function lookupAcoustId(query, request2 = {}) {
|
|
5364
|
+
if (!query.clientKey || !query.fingerprint || !(query.durationSec > 0)) return [];
|
|
5365
|
+
const http = request2.http ?? createFetchOnlineJsonHttp();
|
|
5366
|
+
let body;
|
|
5367
|
+
try {
|
|
5368
|
+
body = await http.getJson(buildAcoustIdLookupUrl(query, request2.origin), { signal: request2.signal });
|
|
5369
|
+
} catch (error) {
|
|
5370
|
+
if (request2.signal?.aborted) throw error;
|
|
5371
|
+
throw new Error(`AcoustID request failed (${error instanceof Error ? error.message : String(error)})`);
|
|
5372
|
+
}
|
|
5373
|
+
const record = body ?? {};
|
|
5374
|
+
if (record.status !== "ok") {
|
|
5375
|
+
throw new Error(`AcoustID lookup refused (${typeof record.error?.message === "string" ? record.error.message : "unknown error"})`);
|
|
5376
|
+
}
|
|
5377
|
+
return (Array.isArray(record.results) ? record.results : []).flatMap((entry) => {
|
|
5378
|
+
const result = entry ?? {};
|
|
5379
|
+
const id = typeof result["id"] === "string" ? result["id"] : "";
|
|
5380
|
+
if (!id) return [];
|
|
5381
|
+
const recordings = Array.isArray(result["recordings"]) ? result["recordings"] : [];
|
|
5382
|
+
return [{
|
|
5383
|
+
id,
|
|
5384
|
+
score: Number(result["score"]) || 0,
|
|
5385
|
+
recordings: recordings.flatMap((recording) => {
|
|
5386
|
+
const item = recording ?? {};
|
|
5387
|
+
if (typeof item["id"] !== "string") return [];
|
|
5388
|
+
const artists = Array.isArray(item["artists"]) ? item["artists"] : [];
|
|
5389
|
+
const artist = artists.map((part) => `${part["name"] ?? ""}${typeof part["joinphrase"] === "string" ? part["joinphrase"] : ""}`).join("").trim();
|
|
5390
|
+
const duration = Number(item["duration"]);
|
|
5391
|
+
return [{
|
|
5392
|
+
id: item["id"],
|
|
5393
|
+
title: typeof item["title"] === "string" ? item["title"] : void 0,
|
|
5394
|
+
artist: artist || void 0,
|
|
5395
|
+
durationSec: Number.isFinite(duration) && duration > 0 ? Math.round(duration) : void 0
|
|
5396
|
+
}];
|
|
5397
|
+
})
|
|
5398
|
+
}];
|
|
5399
|
+
});
|
|
5400
|
+
}
|
|
5401
|
+
|
|
5402
|
+
export { ACOUSTID_ORIGIN, AUDIO_IDENTIFICATION_RETRY_MS, AUDIO_IDENTIFICATION_VERSION, CAST_DEFAULT_MEDIA_RECEIVER, CAST_MDNS_SERVICE, CAST_PORT, ConsoleLogPort, DEFAULT_ADS_CONFIG, DEFAULT_METADATA_CONFIG, DEFAULT_PROGRESS_CONFIG, DEFAULT_RESILIENCE_CONFIG, DEFAULT_SUBTITLES_CONFIG, DEFAULT_UI_CONFIG, DLNA_MEDIA_RENDERER_SEARCH_TARGET, DLNA_MEDIA_SERVER_SEARCH_TARGET, DefaultCacheStore, INTERNET_ARCHIVE_ORIGIN, INTERNET_ARCHIVE_PLAYABLE_FILTERS, JELLYFIN_CLIENT, LRCLIB_ORIGIN, LocalMetadataProvider, LocalSubtitleProvider, MDNS_ADDRESS, MDNS_PORT, MEDIA_LIBRARY_MODEL_VERSION, MUSICBRAINZ_ORIGIN, MemoryStoragePort, NGXSP_SERVICE_TYPE, NativeEngine, NoopTelemetryPort, PLAYBACK_TRANSFER_PATH, PLAYBACK_TRANSFER_PROMPT_MS, PLAYBACK_TRANSFER_VERSION, PlayerFacade, ProgressPlugin, RADIO_BROWSER_ORIGIN, RINGTONE_FADE_SEC, RINGTONE_MAX_SEC, RemotePlaybackEngine, RemotePlaybackError, ResiliencePlugin, SUBSONIC_API_VERSION, SUBSONIC_CLIENT, SimpleEventBus, UpnpActionError, VAULT_ROUNDS, applyAudioIdentification, applyEngineProfileToSource, applyRemoteConfig, audioFieldsFromTags, audioIdentificationDoubts, audioTrackMatchesSearch, bestLrclibMatch, browseDlna, browseJellyfin, browseSubsonic, browseWebDav, buildAcoustIdLookupUrl, buildInternetArchiveSearchUrl, buildLrclibGetUrl, buildLrclibSearchUrl, buildMusicBrainzRecordingSearchUrl, buildRadioBrowserSearchUrl, castContentType, castDevicesFromMdns, cleanAudioArtistName, cleanMediaTitle, connectDlna, connectJellyfin, connectSubsonic, connectWebDav, createAudioLibrarySnapshot, createCapabilityReport, createCastSession, createDefaultCapabilities, createDefaultConfig, createDefaultPorts, createDlnaConnector, createDlnaRendererSession, createFetchOnlineJsonHttp, createIdentificationRoutine, createIdleDevicePlaybackState, createJellyfinConnector, createMediaCache, createMediaDecodingReport, createNativeEngineFactory, createPlaybackTransferReceiver, createRemotePlaybackEngineFactory, createShakeDetector, createSsaiManifestRewritePlugin, createSubsonicConnector, createWebDavConnector, decodeCastFrames, decodeSubtitleBytes, describeSubtitleFile, detectPlaybackCapabilities, encodeCastMessage, encodeMdnsQuery, encodeMdnsResponse, encodeWav, fadeEdges, fetchInternetArchiveItem, fetchLrclibLyrics, fileKeyFor, findUpnpService, folderArtworkRank, formatRemoteClock, getEngineProfileHints, harmonizeAudioMetadata, hashPasscode, hiddenIds, internetArchiveAudioTracks, internetArchiveDetailsUrl, internetArchiveDownloadUrl, internetArchiveThumbnailUrl, isCategorizedEventType, isIgnoredDirectoryName, isImageFileName, isMediaFileName, isUsablePasscode, jellyfinClientHeader, jellyfinImageUrl, jellyfinStreamUrl, lookupAcoustId, lookupMusicBrainzRecording, lyricsFromTags, lyricsLineIndexAt, matchFingerprintedRecording, matchMusicBrainzRecording, md5Hex, mdnsServices, mediaContentType, mediaFileExtension, mediaMetadataKey, mediaSourceItemId, needsAudioIdentification, ngxspPeersFromMdns, ngxspServiceRecords, ngxspTxt, normalizeAudioSearchText, normalizeMediaSourceAddress, parseDidlLite, parseEpisodeFileName, parseInternetArchiveItem, parseInternetArchiveLength, parseJellyfinItem, parseLrc, parseMdnsMessage, parseMdnsQuestions, parseMediaSourceItemId, parseMusicBrainzRecording, parseRadioStation, parseRemoteClock, parseSubsonicSong, parseUpnpDeviceDescription, parseWebDavMultistatus, pickAcoustIdRecording, planMediaCacheEviction, readDlnaRenderer, readJellyfinLyrics, readPlaybackTransfer, readWebDavLyrics, reduceDevicePlaybackState, remotePlaybackErrorCode, remotePlaybackFailure, ringtoneFileName, ringtoneSegment, searchAudioLibrarySnapshot, searchInternetArchive, searchMusicBrainzRecordings, searchRadioStations, selectEngine, selectInternetArchiveVideo, sendPlaybackTransfer, subsonicCoverUrl, subsonicStreamUrl, subtitleTextToVtt, ticksToSeconds, upnpAction, verifyPasscode, videoFieldsFromTags, webDavAuthorization, withoutHidden };
|
|
1330
5403
|
//# sourceMappingURL=index.js.map
|
|
1331
5404
|
//# sourceMappingURL=index.js.map
|