magmastream 2.10.6-alpha.1 → 2.10.6-alpha.3

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.
@@ -532,8 +532,7 @@ class Manager extends events_1.EventEmitter {
532
532
  async restorePlayerFromState(node, nodeId, guildId, state, cleanup) {
533
533
  if (!state.guildId || state.node?.options?.identifier !== nodeId)
534
534
  return;
535
- const hasGuild = this.resolveGuild(state.guildId);
536
- if (!hasGuild)
535
+ if (state.guildId !== guildId)
537
536
  return;
538
537
  const lavaPlayer = (await node.rest.get(`/v4/sessions/${state.node.sessionId}/players/${state.guildId}`));
539
538
  if (!lavaPlayer)
@@ -720,6 +719,9 @@ class Manager extends events_1.EventEmitter {
720
719
  .map(async ({ name: guildId }) => {
721
720
  const stateFilePath = Utils_1.PlayerUtils.getPlayerStatePath(guildId);
722
721
  try {
722
+ // Guilds outside this client's cache belong to another cluster; skip them before reading any file.
723
+ if (!this.resolveGuild(guildId))
724
+ return;
723
725
  await promises_1.default.access(stateFilePath);
724
726
  const state = JSON.parse(await promises_1.default.readFile(stateFilePath, "utf-8"));
725
727
  await this.restorePlayerFromState(node, nodeId, guildId, state, async () => {
@@ -742,15 +744,15 @@ class Manager extends events_1.EventEmitter {
742
744
  const keys = await this.redis.keys(`${Utils_1.PlayerUtils.getRedisKey()}playerstore:*`);
743
745
  await Promise.all(keys.map(async (key) => {
744
746
  try {
747
+ const guildId = key.split(":").pop();
748
+ if (!guildId || !this.resolveGuild(guildId))
749
+ return;
745
750
  const data = await this.redis.get(key);
746
751
  if (!data)
747
752
  return;
748
753
  const state = JSON.parse(data);
749
754
  if (!state || typeof state !== "object")
750
755
  return;
751
- const guildId = key.split(":").pop();
752
- if (!guildId)
753
- return;
754
756
  await this.restorePlayerFromState(node, nodeId, guildId, state, async () => {
755
757
  await this.redis.del(key);
756
758
  this.emit(Enums_1.ManagerEventTypes.Debug, `[MANAGER] Deleted Redis state: ${key}`);
@@ -2,7 +2,7 @@ import { Manager } from "./Manager";
2
2
  import { Player } from "./Player";
3
3
  import { Rest } from "./Rest";
4
4
  import WebSocket from "ws";
5
- import { LavalinkInfo, Lyrics, NodeLinkGetLyrics, NodeLinkChapter, NodeLinkEvent, NodeLinkMix, NodeLinkMeaningResult, NodeLinkSponsorBlockSegment, NodeLinkSponsorBlockUpdate, NodeOptions, NodeStats, NodeLinkSponsorBlockState, PlayerEvent, Track, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent } from "./Types";
5
+ import { FetchLyricsOptions, LavalinkInfo, Lyrics, NormalizedLyrics, NodeLinkGetLyrics, NodeLinkChapter, NodeLinkEvent, NodeLinkMix, NodeLinkMeaningResult, NodeLinkSponsorBlockSegment, NodeLinkSponsorBlockUpdate, NodeOptions, NodeStats, NodeLinkSponsorBlockState, PlayerEvent, Track, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent } from "./Types";
6
6
  import { SponsorBlockSegment } from "./Enums";
7
7
  export declare class Node {
8
8
  manager: Manager;
@@ -251,6 +251,19 @@ export declare class Node {
251
251
  * @returns {Promise<Lyrics | NodeLinkGetLyrics>} A promise that resolves with the lyrics data.
252
252
  */
253
253
  getLyrics(track: Track, skipTrackSource?: boolean, language?: string): Promise<Lyrics | NodeLinkGetLyrics>;
254
+ /**
255
+ * Fetches lyrics and returns them in one shape for Lavalink and NodeLink.
256
+ *
257
+ * On Lavalink, a failure of the track's own lyrics source does not end the lookup.
258
+ * LavaLyrics returns HTTP 500 when any one source throws, so the node is asked again
259
+ * with `skipTrackSource=true`. With `lrclibFallback`, LRCLIB is asked when the node fails or finds nothing.
260
+ *
261
+ * @param {Track} track - The track to fetch the lyrics for.
262
+ * @param {FetchLyricsOptions} [options] - Lookup options.
263
+ * @returns {Promise<NormalizedLyrics | null>} The lyrics, or null when no source has lyrics for the track.
264
+ * @throws {MagmaStreamError} When every lookup failed with an error (node down, missing plugins, server errors).
265
+ */
266
+ fetchLyrics(track: Track, options?: FetchLyricsOptions): Promise<NormalizedLyrics | null>;
254
267
  /**
255
268
  * Loads chapters for a track using NodeLink.
256
269
  * @param {Track} track - The track to load chapters for.
@@ -534,6 +534,7 @@ class Node {
534
534
  if (!player || player.node.options.identifier !== this.options.identifier)
535
535
  return;
536
536
  player.position = payload.state.position ?? 0;
537
+ player.ping = payload.state.ping ?? -1;
537
538
  this.manager.emit(Enums_1.ManagerEventTypes.PlayerUpdate, player, payload);
538
539
  }
539
540
  /**
@@ -1070,7 +1071,60 @@ class Node {
1070
1071
  });
1071
1072
  }
1072
1073
  const lyrics = (await this.rest.get(`/v4/lyrics?track=${encodeURIComponent(track.track)}&skipTrackSource=${skipTrackSource}`));
1073
- return lyrics || { source: null, provider: null, text: null, lines: [], plugin: [] };
1074
+ return lyrics || { sourceName: null, source: null, provider: null, text: null, lines: [], plugin: [] };
1075
+ }
1076
+ /**
1077
+ * Fetches lyrics and returns them in one shape for Lavalink and NodeLink.
1078
+ *
1079
+ * On Lavalink, a failure of the track's own lyrics source does not end the lookup.
1080
+ * LavaLyrics returns HTTP 500 when any one source throws, so the node is asked again
1081
+ * with `skipTrackSource=true`. With `lrclibFallback`, LRCLIB is asked when the node fails or finds nothing.
1082
+ *
1083
+ * @param {Track} track - The track to fetch the lyrics for.
1084
+ * @param {FetchLyricsOptions} [options] - Lookup options.
1085
+ * @returns {Promise<NormalizedLyrics | null>} The lyrics, or null when no source has lyrics for the track.
1086
+ * @throws {MagmaStreamError} When every lookup failed with an error (node down, missing plugins, server errors).
1087
+ */
1088
+ async fetchLyrics(track, options = {}) {
1089
+ const { skipTrackSource = false, retryWithoutTrackSource = true, language, lrclibFallback = false } = options;
1090
+ const origin = this.isNodeLink ? "nodelink" : "lavalink";
1091
+ const attempts = this.isNodeLink || skipTrackSource || !retryWithoutTrackSource ? [skipTrackSource] : [false, true];
1092
+ let lastError = null;
1093
+ let nodeAnswered = false;
1094
+ for (const skip of attempts) {
1095
+ try {
1096
+ const raw = await this.getLyrics(track, skip, language);
1097
+ if (raw && "loadType" in raw && raw.loadType === "error") {
1098
+ throw new MagmastreamError_1.MagmaStreamError({
1099
+ code: Enums_1.MagmaStreamErrorCode.REST_REQUEST_FAILED,
1100
+ message: `NodeLink lyrics request failed: ${raw.data?.message ?? "unknown error"}`,
1101
+ context: { identifier: this.options.identifier },
1102
+ });
1103
+ }
1104
+ nodeAnswered = true;
1105
+ const lyrics = Utils_1.LyricsUtils.normalize(raw, origin);
1106
+ if (lyrics)
1107
+ return lyrics;
1108
+ }
1109
+ catch (error) {
1110
+ lastError = error;
1111
+ this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] Lyrics lookup on node ${this.options.identifier} failed (skipTrackSource=${skip}): ${error instanceof Error ? error.message : String(error)}`);
1112
+ }
1113
+ }
1114
+ if (lrclibFallback) {
1115
+ try {
1116
+ return await Utils_1.LyricsUtils.fetchLrclib(track, { userAgent: options.lrclibUserAgent, timeoutMs: options.lrclibTimeoutMs });
1117
+ }
1118
+ catch (error) {
1119
+ this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[NODE] LRCLIB lyrics lookup failed: ${error instanceof Error ? error.message : String(error)}`);
1120
+ if (nodeAnswered)
1121
+ return null;
1122
+ throw lastError ?? error;
1123
+ }
1124
+ }
1125
+ if (!nodeAnswered && lastError)
1126
+ throw lastError;
1127
+ return null;
1074
1128
  }
1075
1129
  /**
1076
1130
  * Loads chapters for a track using NodeLink.
@@ -1,7 +1,7 @@
1
1
  import { Filters } from "./Filters";
2
2
  import { Manager } from "./Manager";
3
3
  import { Node } from "./Node";
4
- import { AnyMessage, IQueue, Lyrics, NodeLinkChapter, NodeLinkFadingConfig, NodeLinkMeaningResult, NodeLinkMix, NodeLinkNextTrackOptions, NodeLinkSponsorBlockSegment, NodeLinkSponsorBlockState, NodeLinkSponsorBlockUpdate, PlayerOptions, PlayerVoiceState, PlayOptions, SearchQuery, SearchResult, Track } from "./Types";
4
+ import { AnyMessage, FetchLyricsOptions, IQueue, Lyrics, NormalizedLyrics, NodeLinkChapter, NodeLinkFadingConfig, NodeLinkMeaningResult, NodeLinkMix, NodeLinkNextTrackOptions, NodeLinkSponsorBlockSegment, NodeLinkSponsorBlockState, NodeLinkSponsorBlockUpdate, PlayerOptions, PlayerVoiceState, PlayOptions, SearchQuery, SearchResult, Track } from "./Types";
5
5
  import { SponsorBlockSegment, StateTypes } from "./Enums";
6
6
  import { WebSocket } from "ws";
7
7
  export declare class Player {
@@ -18,6 +18,8 @@ export declare class Player {
18
18
  dynamicRepeat: boolean;
19
19
  /** The time the player is in the track. */
20
20
  position: number;
21
+ /** Last node-reported Discord voice ping in milliseconds; -1 when unavailable. */
22
+ ping: number;
21
23
  /** Whether the player is playing. */
22
24
  playing: boolean;
23
25
  /** Whether the player is paused. */
@@ -84,6 +86,8 @@ export declare class Player {
84
86
  * @returns {T} - The data associated with the key, cast to the specified type.
85
87
  */
86
88
  get<T>(key: string): T;
89
+ /** Returns the last node-reported Discord voice ping in milliseconds, or -1 when unavailable. */
90
+ getPing(): number;
87
91
  /**
88
92
  * Same as Manager#search() but a shortcut on the player itself.
89
93
  * @param query
@@ -369,11 +373,19 @@ export declare class Player {
369
373
  */
370
374
  getSerializableData(): Record<string, unknown>;
371
375
  /**
372
- * Retrieves the current lyrics for the playing track.
376
+ * Retrieves the raw lyrics response for the playing track.
377
+ * On NodeLink the response is NodeLink's shape, not {@link Lyrics}. Use {@link Player.fetchCurrentLyrics} for one shape.
373
378
  * @param skipTrackSource - Indicates whether to skip the track source when fetching lyrics.
374
379
  * @returns {Promise<Lyrics>} - The lyrics of the current track.
375
380
  */
376
381
  getCurrentLyrics(skipTrackSource?: boolean): Promise<Lyrics>;
382
+ /**
383
+ * Fetches lyrics for the playing track in one shape for Lavalink and NodeLink.
384
+ * See {@link Node.fetchLyrics} for the lookup order and options.
385
+ * @param options - Lookup options.
386
+ * @returns {Promise<NormalizedLyrics | null>} - The lyrics, or null when nothing is playing or no source has lyrics.
387
+ */
388
+ fetchCurrentLyrics(options?: FetchLyricsOptions): Promise<NormalizedLyrics | null>;
377
389
  /**
378
390
  * Sets up the voice receiver for the player.
379
391
  * @returns {Promise<void>} - A promise that resolves when the voice receiver is set up.
@@ -26,6 +26,8 @@ class Player {
26
26
  dynamicRepeat = false;
27
27
  /** The time the player is in the track. */
28
28
  position = 0;
29
+ /** Last node-reported Discord voice ping in milliseconds; -1 when unavailable. */
30
+ ping = -1;
29
31
  /** Whether the player is playing. */
30
32
  playing = false;
31
33
  /** Whether the player is paused. */
@@ -162,6 +164,10 @@ class Player {
162
164
  // Access the data object using the key and cast it to the specified type T.
163
165
  return this.data[key];
164
166
  }
167
+ /** Returns the last node-reported Discord voice ping in milliseconds, or -1 when unavailable. */
168
+ getPing() {
169
+ return this.ping;
170
+ }
165
171
  /**
166
172
  * Same as Manager#search() but a shortcut on the player itself.
167
173
  * @param query
@@ -1184,7 +1190,8 @@ class Player {
1184
1190
  return { ...this.data };
1185
1191
  }
1186
1192
  /**
1187
- * Retrieves the current lyrics for the playing track.
1193
+ * Retrieves the raw lyrics response for the playing track.
1194
+ * On NodeLink the response is NodeLink's shape, not {@link Lyrics}. Use {@link Player.fetchCurrentLyrics} for one shape.
1188
1195
  * @param skipTrackSource - Indicates whether to skip the track source when fetching lyrics.
1189
1196
  * @returns {Promise<Lyrics>} - The lyrics of the current track.
1190
1197
  */
@@ -1194,6 +1201,7 @@ class Player {
1194
1201
  // If no lyrics are found, return a default empty lyrics object
1195
1202
  if (!result) {
1196
1203
  result = {
1204
+ sourceName: null,
1197
1205
  source: null,
1198
1206
  provider: null,
1199
1207
  text: null,
@@ -1203,6 +1211,18 @@ class Player {
1203
1211
  }
1204
1212
  return result;
1205
1213
  }
1214
+ /**
1215
+ * Fetches lyrics for the playing track in one shape for Lavalink and NodeLink.
1216
+ * See {@link Node.fetchLyrics} for the lookup order and options.
1217
+ * @param options - Lookup options.
1218
+ * @returns {Promise<NormalizedLyrics | null>} - The lyrics, or null when nothing is playing or no source has lyrics.
1219
+ */
1220
+ async fetchCurrentLyrics(options = {}) {
1221
+ const current = await this.queue.getCurrent();
1222
+ if (!current)
1223
+ return null;
1224
+ return this.node.fetchLyrics(current, options);
1225
+ }
1206
1226
  /**
1207
1227
  * Sets up the voice receiver for the player.
1208
1228
  * @returns {Promise<void>} - A promise that resolves when the voice receiver is set up.
@@ -1184,24 +1184,75 @@ export interface LavalinkInfo {
1184
1184
  */
1185
1185
  export interface LyricsLine {
1186
1186
  timestamp: number;
1187
- duration: number;
1187
+ duration: number | null;
1188
1188
  line: string;
1189
1189
  words?: Array<Record<string, unknown>>;
1190
1190
  plugin: Record<string, unknown>;
1191
1191
  }
1192
1192
  /**
1193
- * Lyrics interface
1193
+ * Lyrics interface. The raw LavaLyrics response from `GET /v4/lyrics`.
1194
1194
  */
1195
1195
  export interface Lyrics {
1196
- /** Lavalink lyrics plugin source field. */
1197
- source?: string | null;
1198
- /** NodeLink lyrics source field. */
1196
+ /** LavaLyrics source name, for example "spotify", "deezer" or "LRCLIB". */
1199
1197
  sourceName?: string | null;
1198
+ /** @deprecated LavaLyrics sends `sourceName`. This field is never set by Lavalink. */
1199
+ source?: string | null;
1200
1200
  provider: string | null;
1201
1201
  text?: string | null;
1202
1202
  lines: LyricsLine[];
1203
1203
  plugin: Record<string, unknown> | Array<Record<string, unknown>>;
1204
1204
  }
1205
+ /**
1206
+ * One line of {@link NormalizedLyrics}.
1207
+ */
1208
+ export interface NormalizedLyricsLine {
1209
+ /** Start of the line in milliseconds. Null when the lyrics are not synced. */
1210
+ timestamp: number | null;
1211
+ /** Length of the line in milliseconds, when the source sends it. */
1212
+ duration: number | null;
1213
+ /** The line text, trimmed. An empty string marks a break between verses. */
1214
+ line: string;
1215
+ }
1216
+ /**
1217
+ * Lyrics in one shape for Lavalink (LavaLyrics), NodeLink and LRCLIB.
1218
+ * Returned by `Node.fetchLyrics` and `Player.fetchCurrentLyrics`.
1219
+ */
1220
+ export interface NormalizedLyrics {
1221
+ /** The source that found the lyrics, for example "spotify", "deezer" or "LRCLIB". */
1222
+ sourceName: string | null;
1223
+ /** The lyrics provider, for example "MusixMatch" or "LRCLIB". */
1224
+ provider: string | null;
1225
+ /** The plain text, when the source sends it. */
1226
+ text: string | null;
1227
+ /** True when every line has a timestamp and the timestamps are not all 0. */
1228
+ synced: boolean;
1229
+ lines: NormalizedLyricsLine[];
1230
+ /** The backend that answered. */
1231
+ origin: "lavalink" | "nodelink" | "lrclib";
1232
+ }
1233
+ /**
1234
+ * Options for `Node.fetchLyrics` and `Player.fetchCurrentLyrics`.
1235
+ */
1236
+ export interface FetchLyricsOptions {
1237
+ /** Skip the track's own source and ask the node's lyrics sources in priority order. Default: false. */
1238
+ skipTrackSource?: boolean;
1239
+ /**
1240
+ * When the track's own source fails or finds nothing, ask the node again with `skipTrackSource=true`.
1241
+ * Lavalink only. Default: true.
1242
+ */
1243
+ retryWithoutTrackSource?: boolean;
1244
+ /** NodeLink only: the lyrics language. */
1245
+ language?: string;
1246
+ /**
1247
+ * Ask LRCLIB (https://lrclib.net) when the node fails or finds nothing.
1248
+ * This sends the track title, artist and length to lrclib.net. Default: false.
1249
+ */
1250
+ lrclibFallback?: boolean;
1251
+ /** User-Agent sent to LRCLIB. LRCLIB asks clients to name themselves. Default: "magmastream". */
1252
+ lrclibUserAgent?: string;
1253
+ /** Timeout for each LRCLIB request, in milliseconds. Default: 8000. */
1254
+ lrclibTimeoutMs?: number;
1255
+ }
1205
1256
  /**
1206
1257
  * LyricsFoundEvent interface
1207
1258
  */
@@ -1,6 +1,6 @@
1
1
  import { AutoPlayPlatform, TrackPartial } from "./Enums";
2
2
  import { Manager } from "./Manager";
3
- import { AnyUser, ErrorOrEmptySearchResult, Extendable, LavalinkResponse, SearchResult, SerializedPlayerState, Track, TrackData } from "./Types";
3
+ import { AnyUser, ErrorOrEmptySearchResult, Extendable, LavalinkResponse, Lyrics, NodeLinkGetLyrics, NormalizedLyrics, NormalizedLyricsLine, SearchResult, SerializedPlayerState, Track, TrackData } from "./Types";
4
4
  import { Player } from "./Player";
5
5
  export declare abstract class TrackUtils {
6
6
  static trackPartial: TrackPartial[] | null;
@@ -163,7 +163,61 @@ export declare abstract class Structure {
163
163
  */
164
164
  static get<K extends keyof Extendable>(name: K): Extendable[K];
165
165
  }
166
+ /** Track fields the LRCLIB lookup reads. */
167
+ type LyricsTrack = Pick<Track, "title" | "author" | "duration" | "isStream">;
168
+ /**
169
+ * Turns Lavalink (LavaLyrics), NodeLink and LRCLIB lyrics into one shape,
170
+ * and looks lyrics up on LRCLIB.
171
+ */
172
+ export declare abstract class LyricsUtils {
173
+ /**
174
+ * Normalizes a raw `Node.getLyrics` response.
175
+ * @returns The lyrics, or null when the response holds no lyrics.
176
+ */
177
+ static normalize(raw: Lyrics | NodeLinkGetLyrics | null | undefined, origin: "lavalink" | "nodelink"): NormalizedLyrics | null;
178
+ private static normalizeNodeLink;
179
+ private static normalizeNodeLinkData;
180
+ /**
181
+ * Looks a track up on LRCLIB: an exact match first, then the search result closest in length.
182
+ * @returns The lyrics, or null when LRCLIB has none.
183
+ * @throws When LRCLIB cannot be reached or answers with an error.
184
+ */
185
+ static fetchLrclib(track: LyricsTrack, options?: {
186
+ userAgent?: string;
187
+ timeoutMs?: number;
188
+ }): Promise<NormalizedLyrics | null>;
189
+ /**
190
+ * One LRCLIB request. A 404 means LRCLIB has no such song and gives null.
191
+ */
192
+ static lrclibRequest<T>(path: string, params: Record<string, string | number>, options?: {
193
+ userAgent?: string;
194
+ timeoutMs?: number;
195
+ }): Promise<T | null>;
196
+ private static fromLrclibRecord;
197
+ /**
198
+ * Parses LRC text. Accepts "[mm:ss.xx]text" with or without a space after the timestamp,
199
+ * more than one timestamp per line, and skips tags such as "[ar:Artist]".
200
+ */
201
+ static parseLrc(text: string): NormalizedLyricsLine[];
202
+ /**
203
+ * Removes video noise so a lyrics database can match the track.
204
+ * "Coldplay - Yellow (Official Video)" by "ColdplayVEVO" gives artist "Coldplay" and title "Yellow".
205
+ */
206
+ static cleanTrackInfo(track: Pick<Track, "title" | "author">): {
207
+ artist: string;
208
+ title: string;
209
+ };
210
+ /**
211
+ * Trims lines and decides if the lyrics are synced. Unsynced lyrics get null timestamps.
212
+ * @returns The lyrics, or null when no line has text.
213
+ */
214
+ private static finish;
215
+ private static linesFromText;
216
+ private static finiteOrNull;
217
+ private static looseIncludes;
218
+ }
166
219
  export declare abstract class JSONUtils {
167
220
  static safe<T>(obj: T, space?: number): string;
168
221
  static serializeTrack(track: Track): string;
169
222
  }
223
+ export {};
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.JSONUtils = exports.Structure = exports.PlayerUtils = exports.AutoPlayUtils = exports.TrackUtils = void 0;
3
+ exports.JSONUtils = exports.LyricsUtils = exports.Structure = exports.PlayerUtils = exports.AutoPlayUtils = exports.TrackUtils = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  /* eslint-disable @typescript-eslint/no-require-imports */
6
6
  const undici_1 = require("undici");
@@ -887,6 +887,235 @@ class Structure {
887
887
  }
888
888
  }
889
889
  exports.Structure = Structure;
890
+ const LRCLIB_API = "https://lrclib.net/api";
891
+ const LRCLIB_DEFAULT_USER_AGENT = "magmastream (https://gitryx.com/MagmaStream/magmastream)";
892
+ const LRCLIB_DEFAULT_TIMEOUT_MS = 8000;
893
+ /** A search result whose length is further off than this is another recording. */
894
+ const LRCLIB_MAX_DURATION_DIFF_S = 15;
895
+ const LRC_STAMP = /^\[(\d+):(\d{1,2}(?:[.:]\d+)?)\]/;
896
+ const TITLE_NOISE = /[([][^)\]]*\b(official|video|audio|lyrics?|visuali[sz]er|remaster(ed)?|hd|hq|4k|mv|live|explicit|clean)\b[^)\]]*[)\]]/gi;
897
+ /**
898
+ * Turns Lavalink (LavaLyrics), NodeLink and LRCLIB lyrics into one shape,
899
+ * and looks lyrics up on LRCLIB.
900
+ */
901
+ class LyricsUtils {
902
+ /**
903
+ * Normalizes a raw `Node.getLyrics` response.
904
+ * @returns The lyrics, or null when the response holds no lyrics.
905
+ */
906
+ static normalize(raw, origin) {
907
+ if (!raw || typeof raw !== "object")
908
+ return null;
909
+ if ("loadType" in raw)
910
+ return this.normalizeNodeLink(raw);
911
+ let lines = Array.isArray(raw.lines)
912
+ ? raw.lines.map((line) => ({
913
+ timestamp: this.finiteOrNull(line?.timestamp),
914
+ duration: this.finiteOrNull(line?.duration),
915
+ line: typeof line?.line === "string" ? line.line : "",
916
+ }))
917
+ : [];
918
+ // Some sources send only plain text.
919
+ if (!lines.some((line) => line.line.trim()) && typeof raw.text === "string") {
920
+ lines = this.linesFromText(raw.text);
921
+ }
922
+ return this.finish(lines, {
923
+ sourceName: raw.sourceName ?? raw.source ?? null,
924
+ provider: raw.provider ?? null,
925
+ text: typeof raw.text === "string" ? raw.text : null,
926
+ origin,
927
+ });
928
+ }
929
+ static normalizeNodeLink(raw) {
930
+ switch (raw.loadType) {
931
+ case "lyrics": {
932
+ const data = raw.data ?? {};
933
+ const lines = (data.lines ?? []).map((line) => ({
934
+ timestamp: this.finiteOrNull(line?.time),
935
+ duration: this.finiteOrNull(line?.duration),
936
+ line: typeof line?.text === "string" ? line.text : "",
937
+ }));
938
+ return this.finish(lines, { sourceName: data.provider ?? data.name ?? null, provider: data.provider ?? null, text: null, origin: "nodelink" }, data.synced);
939
+ }
940
+ case "lyricsSingle":
941
+ return this.normalizeNodeLinkData(raw.data);
942
+ case "lyricsMultiple": {
943
+ const entries = Array.isArray(raw.data) ? [...raw.data] : [];
944
+ // Prefer synced lyrics.
945
+ entries.sort((a, b) => Number(Boolean(b?.synced)) - Number(Boolean(a?.synced)));
946
+ for (const entry of entries) {
947
+ const lyrics = this.normalizeNodeLinkData(entry);
948
+ if (lyrics)
949
+ return lyrics;
950
+ }
951
+ return null;
952
+ }
953
+ default:
954
+ return null;
955
+ }
956
+ }
957
+ static normalizeNodeLinkData(data) {
958
+ if (!data)
959
+ return null;
960
+ const lines = (data.data ?? []).map((line) => {
961
+ const start = this.finiteOrNull(line?.startTime);
962
+ const end = this.finiteOrNull(line?.endTime);
963
+ return {
964
+ timestamp: start,
965
+ duration: start !== null && end !== null && end >= start ? end - start : null,
966
+ line: typeof line?.text === "string" ? line.text : "",
967
+ };
968
+ });
969
+ return this.finish(lines, { sourceName: data.name ?? null, provider: data.name ?? null, text: null, origin: "nodelink" }, data.synced);
970
+ }
971
+ /**
972
+ * Looks a track up on LRCLIB: an exact match first, then the search result closest in length.
973
+ * @returns The lyrics, or null when LRCLIB has none.
974
+ * @throws When LRCLIB cannot be reached or answers with an error.
975
+ */
976
+ static async fetchLrclib(track, options = {}) {
977
+ const { artist, title } = this.cleanTrackInfo(track);
978
+ if (!title)
979
+ return null;
980
+ const durationS = !track.isStream && track.duration > 0 ? Math.round(track.duration / 1000) : null;
981
+ if (artist && durationS) {
982
+ const exact = await this.lrclibRequest("/get", { track_name: title, artist_name: artist, duration: durationS }, options);
983
+ const lyrics = exact ? this.fromLrclibRecord(exact) : null;
984
+ if (lyrics)
985
+ return lyrics;
986
+ }
987
+ let records = await this.lrclibRequest("/search", artist ? { track_name: title, artist_name: artist } : { q: title }, options);
988
+ if ((!Array.isArray(records) || !records.length) && artist) {
989
+ records = await this.lrclibRequest("/search", { q: `${artist} ${title}` }, options);
990
+ }
991
+ if (!Array.isArray(records))
992
+ return null;
993
+ const candidates = records
994
+ .filter((record) => record && (record.syncedLyrics || record.plainLyrics))
995
+ .map((record) => ({ record, diff: durationS && record.duration ? Math.abs(record.duration - durationS) : 0 }))
996
+ .filter(({ diff }) => diff <= LRCLIB_MAX_DURATION_DIFF_S)
997
+ // The closest length wins. Within 2 seconds, synced lyrics win.
998
+ .sort((a, b) => (Math.abs(a.diff - b.diff) > 2 ? a.diff - b.diff : Number(Boolean(b.record.syncedLyrics)) - Number(Boolean(a.record.syncedLyrics))));
999
+ for (const { record } of candidates) {
1000
+ const lyrics = this.fromLrclibRecord(record);
1001
+ if (lyrics)
1002
+ return lyrics;
1003
+ }
1004
+ return null;
1005
+ }
1006
+ /**
1007
+ * One LRCLIB request. A 404 means LRCLIB has no such song and gives null.
1008
+ */
1009
+ static async lrclibRequest(path, params, options = {}) {
1010
+ const url = new URL(LRCLIB_API + path);
1011
+ for (const [key, value] of Object.entries(params))
1012
+ url.searchParams.set(key, String(value));
1013
+ const response = await (0, undici_1.fetch)(url, {
1014
+ headers: { Accept: "application/json", "User-Agent": options.userAgent ?? LRCLIB_DEFAULT_USER_AGENT },
1015
+ signal: AbortSignal.timeout(options.timeoutMs ?? LRCLIB_DEFAULT_TIMEOUT_MS),
1016
+ });
1017
+ if (response.status === 404)
1018
+ return null;
1019
+ if (!response.ok) {
1020
+ throw new MagmastreamError_1.MagmaStreamError({
1021
+ code: Enums_1.MagmaStreamErrorCode.REST_REQUEST_FAILED,
1022
+ message: `LRCLIB request ${path} failed (${response.status}).`,
1023
+ });
1024
+ }
1025
+ return (await response.json());
1026
+ }
1027
+ static fromLrclibRecord(record) {
1028
+ const meta = { sourceName: "LRCLIB", provider: "LRCLIB", text: record.plainLyrics ?? null, origin: "lrclib" };
1029
+ if (record.syncedLyrics) {
1030
+ const lyrics = this.finish(this.parseLrc(record.syncedLyrics), meta);
1031
+ if (lyrics)
1032
+ return lyrics;
1033
+ }
1034
+ return record.plainLyrics ? this.finish(this.linesFromText(record.plainLyrics), meta) : null;
1035
+ }
1036
+ /**
1037
+ * Parses LRC text. Accepts "[mm:ss.xx]text" with or without a space after the timestamp,
1038
+ * more than one timestamp per line, and skips tags such as "[ar:Artist]".
1039
+ */
1040
+ static parseLrc(text) {
1041
+ const lines = [];
1042
+ for (const raw of text.split("\n")) {
1043
+ let rest = raw.trim();
1044
+ const stamps = [];
1045
+ let match;
1046
+ while ((match = LRC_STAMP.exec(rest))) {
1047
+ const seconds = Number.parseFloat(match[2].replace(":", "."));
1048
+ stamps.push(Number.parseInt(match[1], 10) * 60000 + Math.round(seconds * 1000));
1049
+ rest = rest.slice(match[0].length);
1050
+ }
1051
+ for (const timestamp of stamps)
1052
+ lines.push({ timestamp, duration: null, line: rest.trim() });
1053
+ }
1054
+ return lines.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
1055
+ }
1056
+ /**
1057
+ * Removes video noise so a lyrics database can match the track.
1058
+ * "Coldplay - Yellow (Official Video)" by "ColdplayVEVO" gives artist "Coldplay" and title "Yellow".
1059
+ */
1060
+ static cleanTrackInfo(track) {
1061
+ let artist = String(track.author ?? "")
1062
+ .replace(/\s*-\s*topic$/i, "")
1063
+ .replace(/vevo$/i, "")
1064
+ .replace(/\s*(official)?\s*$/i, "")
1065
+ .trim();
1066
+ let title = String(track.title ?? "");
1067
+ const dash = title.match(/^(.+?)\s+[-–—]\s+(.+)$/);
1068
+ if (dash) {
1069
+ const titleArtist = dash[1].trim();
1070
+ if (!artist || this.looseIncludes(titleArtist, artist) || this.looseIncludes(artist, titleArtist)) {
1071
+ artist = titleArtist;
1072
+ title = dash[2];
1073
+ }
1074
+ }
1075
+ title = title
1076
+ .replace(TITLE_NOISE, "")
1077
+ .replace(/\s+[([]?(feat\.?|ft\.?|featuring)\s.*$/i, "")
1078
+ .replace(/\s+[|/]\s+.*$/, "")
1079
+ .replace(/\s{2,}/g, " ")
1080
+ .trim();
1081
+ artist = artist.split(/\s*(?:,|&|\bfeat\.?|\bft\.?|\bx\b)\s*/i)[0].trim();
1082
+ return { artist, title };
1083
+ }
1084
+ /**
1085
+ * Trims lines and decides if the lyrics are synced. Unsynced lyrics get null timestamps.
1086
+ * @returns The lyrics, or null when no line has text.
1087
+ */
1088
+ static finish(lines, meta, syncedHint) {
1089
+ const cleaned = lines.map((line) => ({ ...line, line: line.line.replace(/\r/g, "").trim() }));
1090
+ if (!cleaned.some((line) => line.line))
1091
+ return null;
1092
+ // Unsynced Spotify lyrics come with every timestamp at 0.
1093
+ const synced = syncedHint !== false && cleaned.length > 1 && cleaned.every((line) => line.timestamp !== null) && cleaned.some((line) => (line.timestamp ?? 0) > 0);
1094
+ if (synced) {
1095
+ cleaned.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
1096
+ }
1097
+ else {
1098
+ for (const line of cleaned) {
1099
+ line.timestamp = null;
1100
+ line.duration = null;
1101
+ }
1102
+ }
1103
+ return { ...meta, synced, lines: cleaned };
1104
+ }
1105
+ static linesFromText(text) {
1106
+ return text.split("\n").map((line) => ({ timestamp: null, duration: null, line }));
1107
+ }
1108
+ static finiteOrNull(value) {
1109
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1110
+ }
1111
+ static looseIncludes(a, b) {
1112
+ const fold = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
1113
+ const left = fold(a);
1114
+ const right = fold(b);
1115
+ return Boolean(left && right && left.includes(right));
1116
+ }
1117
+ }
1118
+ exports.LyricsUtils = LyricsUtils;
890
1119
  class JSONUtils {
891
1120
  static safe(obj, space) {
892
1121
  return (0, safe_stable_stringify_1.default)(obj, null, space);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magmastream",
3
- "version": "2.10.6-alpha.1",
3
+ "version": "2.10.6-alpha.3",
4
4
  "description": "A user-friendly Lavalink client designed for NodeJS.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "prepare": "npm run build",
12
12
  "build": "tsc",
13
13
  "types": "rtb --dist dist",
14
- "test": "npm run build && node --test tests/player-state.test.cjs tests/hot-paths.test.cjs tests/restore-performance.test.cjs",
14
+ "test": "npm run build && node --test tests/player-state.test.cjs tests/hot-paths.test.cjs tests/restore-performance.test.cjs tests/lyrics.test.cjs",
15
15
  "format": "prettier --write .",
16
16
  "format:check": "prettier --check .",
17
17
  "lint": "eslint \"src/**/*.{ts,js}\"",