@ngxsp/core 0.0.1-next.1 → 0.1.0

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