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