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