magmastream 2.10.6-alpha.2 → 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.
@@ -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.
@@ -1071,7 +1071,60 @@ class Node {
1071
1071
  });
1072
1072
  }
1073
1073
  const lyrics = (await this.rest.get(`/v4/lyrics?track=${encodeURIComponent(track.track)}&skipTrackSource=${skipTrackSource}`));
1074
- 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;
1075
1128
  }
1076
1129
  /**
1077
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 {
@@ -373,11 +373,19 @@ export declare class Player {
373
373
  */
374
374
  getSerializableData(): Record<string, unknown>;
375
375
  /**
376
- * 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.
377
378
  * @param skipTrackSource - Indicates whether to skip the track source when fetching lyrics.
378
379
  * @returns {Promise<Lyrics>} - The lyrics of the current track.
379
380
  */
380
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>;
381
389
  /**
382
390
  * Sets up the voice receiver for the player.
383
391
  * @returns {Promise<void>} - A promise that resolves when the voice receiver is set up.
@@ -1190,7 +1190,8 @@ class Player {
1190
1190
  return { ...this.data };
1191
1191
  }
1192
1192
  /**
1193
- * 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.
1194
1195
  * @param skipTrackSource - Indicates whether to skip the track source when fetching lyrics.
1195
1196
  * @returns {Promise<Lyrics>} - The lyrics of the current track.
1196
1197
  */
@@ -1200,6 +1201,7 @@ class Player {
1200
1201
  // If no lyrics are found, return a default empty lyrics object
1201
1202
  if (!result) {
1202
1203
  result = {
1204
+ sourceName: null,
1203
1205
  source: null,
1204
1206
  provider: null,
1205
1207
  text: null,
@@ -1209,6 +1211,18 @@ class Player {
1209
1211
  }
1210
1212
  return result;
1211
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
+ }
1212
1226
  /**
1213
1227
  * Sets up the voice receiver for the player.
1214
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.2",
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}\"",