// src/errors/RequestError.ts
var RequestError = class extends Error {
  status;
  statusText;
  url;
  constructor(message = "The LRCLIB request failed", options = {}) {
    super(message, { cause: options.cause });
    this.name = "RequestError";
    this.status = options.status;
    this.statusText = options.statusText;
    this.url = options.url;
  }
};
var KeyError = class extends Error {
  constructor() {
    super("A publish token has not been configured");
    this.name = "KeyError";
  }
};

// src/errors/NotFound.ts
var NotFoundError = class extends RequestError {
  constructor(message = "Track was not found", options = {}) {
    super(message, { ...options, status: 404 });
    this.name = "NotFoundError";
  }
};
var NoResultError = class extends Error {
  constructor(message = "No result was found") {
    super(message);
    this.name = "NoResultError";
  }
};

// src/utils.ts
var TIMESTAMP_SOURCE = String.raw`\[(\d+):([0-5]\d)(?:[.:](\d{1,3}))?\]`;
var METADATA_LINE = /^\[[a-z][a-z0-9_-]*:.*\]$/i;
function timestampPattern() {
  return new RegExp(TIMESTAMP_SOURCE, "g");
}
function parseTime(time) {
  const match = /^(\d+):([0-5]\d)(?:[.:](\d{1,3}))?$/.exec(time.trim());
  if (!match) {
    throw new TypeError(`Invalid LRC timestamp: ${time}`);
  }
  const minutes = Number(match[1]);
  const seconds = Number(match[2]);
  const fraction = match[3] ? Number(match[3]) / 10 ** match[3].length : 0;
  return minutes * 60 + seconds + fraction;
}
function parseLocalLyrics(lyrics) {
  const synced = [];
  const unsynced = [];
  for (const rawLine of lyrics.replace(/\r\n?/g, "\n").split("\n")) {
    const line = rawLine.trim();
    if (!line || METADATA_LINE.test(line)) continue;
    const matches = [...line.matchAll(timestampPattern())];
    if (matches.length === 0) {
      unsynced.push({ text: line });
      continue;
    }
    const text = line.replace(timestampPattern(), "").trim();
    if (!text) continue;
    for (const match of matches) {
      const timestamp = `${match[1]}:${match[2]}${match[3] ? `.${match[3]}` : ""}`;
      synced.push({ text, startTime: parseTime(timestamp) });
    }
  }
  return {
    synced: synced.length > 0 ? synced : null,
    unsynced
  };
}

// src/client.ts
var DEFAULT_BASE_URL = "https://lrclib.net/api";
var DEFAULT_TIMEOUT_MS = 15e3;
function isRecord(value) {
  return typeof value === "object" && value !== null;
}
function isNullableString(value) {
  return typeof value === "string" || value === null;
}
function isLyricsResponse(value) {
  if (!isRecord(value)) return false;
  return typeof value.id === "number" && Number.isSafeInteger(value.id) && typeof value.name === "string" && typeof value.trackName === "string" && typeof value.artistName === "string" && typeof value.albumName === "string" && typeof value.duration === "number" && Number.isFinite(value.duration) && typeof value.instrumental === "boolean" && isNullableString(value.plainLyrics) && isNullableString(value.syncedLyrics);
}
function normalizeBaseUrl(value, hasPublishToken) {
  let url;
  try {
    url = new URL(value);
  } catch (cause) {
    throw new TypeError("The LRCLIB base URL is invalid", { cause });
  }
  if (url.protocol !== "https:" && url.protocol !== "http:") {
    throw new TypeError("The LRCLIB base URL must use HTTP or HTTPS");
  }
  if (url.username || url.password) {
    throw new TypeError("The LRCLIB base URL must not contain credentials");
  }
  if (url.search || url.hash) {
    throw new TypeError("The LRCLIB base URL must not contain a query or hash");
  }
  if (hasPublishToken && url.protocol !== "https:") {
    throw new TypeError("HTTPS is required when a publish token is configured");
  }
  url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
  return url;
}
function durationInSeconds(durationMs) {
  if (durationMs === void 0) return void 0;
  if (!Number.isFinite(durationMs) || durationMs < 0) {
    throw new RangeError("Track duration must be a non-negative finite number");
  }
  return durationMs / 1e3;
}
function validateText(value, field) {
  if (typeof value !== "string" || !value.trim()) {
    throw new TypeError(`${field} must not be empty`);
  }
}
var Client = class {
  baseUrl;
  publishToken;
  timeoutMs;
  fetchImplementation;
  constructor(options = {}) {
    const publishToken = options.key?.trim() || void 0;
    this.baseUrl = normalizeBaseUrl(
      options.url ?? DEFAULT_BASE_URL,
      publishToken !== void 0
    );
    const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
      throw new RangeError("timeoutMs must be a non-negative finite number");
    }
    const fetchImplementation = options.fetch ?? globalThis.fetch;
    if (typeof fetchImplementation !== "function") {
      throw new TypeError(
        "No fetch implementation is available; pass one in ClientOptions"
      );
    }
    this.publishToken = publishToken;
    this.timeoutMs = timeoutMs;
    this.fetchImplementation = fetchImplementation;
  }
  createUrl(path, query = {}) {
    const url = new URL(path.replace(/^\/+/, ""), this.baseUrl);
    for (const [key, value] of Object.entries(query)) {
      if (value !== void 0 && value !== "") {
        url.searchParams.set(key, String(value));
      }
    }
    return url;
  }
  safeUrl(url) {
    return `${url.origin}${url.pathname}`;
  }
  async request(url, options = {}) {
    const controller = new AbortController();
    let timedOut = false;
    let timeout;
    const abortFromCaller = () => controller.abort();
    if (options.signal?.aborted) {
      controller.abort();
    } else {
      options.signal?.addEventListener("abort", abortFromCaller, {
        once: true
      });
    }
    if (this.timeoutMs > 0) {
      timeout = setTimeout(() => {
        timedOut = true;
        controller.abort();
      }, this.timeoutMs);
    }
    try {
      return await this.fetchImplementation(url, {
        ...options,
        signal: controller.signal
      });
    } catch (cause) {
      const message = timedOut ? `LRCLIB request timed out after ${this.timeoutMs} ms` : options.signal?.aborted ? "LRCLIB request was aborted" : "LRCLIB network request failed";
      throw new RequestError(message, {
        cause,
        url: this.safeUrl(url)
      });
    } finally {
      if (timeout !== void 0) clearTimeout(timeout);
      options.signal?.removeEventListener("abort", abortFromCaller);
    }
  }
  httpError(response, url) {
    const suffix = response.statusText ? ` ${response.statusText}` : "";
    return new RequestError(
      `LRCLIB request failed with status ${response.status}${suffix}`,
      {
        status: response.status,
        statusText: response.statusText,
        url: this.safeUrl(url)
      }
    );
  }
  async readJson(response, url) {
    try {
      return await response.json();
    } catch (cause) {
      throw new RequestError("LRCLIB returned an invalid JSON response", {
        cause,
        status: response.status,
        statusText: response.statusText,
        url: this.safeUrl(url)
      });
    }
  }
  /** Searches LRCLIB by free text or track metadata. */
  async searchLyrics(info, options) {
    if ("query" in info) {
      validateText(info.query, "query");
    } else if ("track_name" in info) {
      validateText(info.track_name, "track_name");
    } else {
      throw new TypeError("A query or track_name must be provided");
    }
    const url = this.createUrl("search", {
      q: "query" in info ? info.query : void 0,
      track_name: "track_name" in info ? info.track_name : void 0,
      artist_name: info.artist_name,
      duration: durationInSeconds(info.duration)
    });
    const response = await this.request(url, options);
    if (!response.ok) throw this.httpError(response, url);
    const body = await this.readJson(response, url);
    if (body === null) throw new NoResultError();
    if (!Array.isArray(body) || !body.every(isLyricsResponse)) {
      throw new RequestError("LRCLIB returned an invalid search response", {
        status: response.status,
        url: this.safeUrl(url)
      });
    }
    return body;
  }
  /** Gets a track by LRCLIB ID or exact metadata. */
  async findLyrics(info, options) {
    let url;
    if ("id" in info && info.id !== void 0) {
      if (!Number.isSafeInteger(info.id) || info.id <= 0) {
        throw new RangeError("Track ID must be a positive safe integer");
      }
      url = this.createUrl(`get/${info.id}`);
    } else {
      validateText(info.track_name, "track_name");
      validateText(info.artist_name, "artist_name");
      url = this.createUrl("get", {
        track_name: info.track_name,
        artist_name: info.artist_name,
        album_name: info.album_name,
        duration: durationInSeconds(info.duration)
      });
    }
    const response = await this.request(url, options);
    if (response.status === 404) {
      throw new NotFoundError("Track was not found", {
        statusText: response.statusText,
        url: this.safeUrl(url)
      });
    }
    if (!response.ok) throw this.httpError(response, url);
    const body = await this.readJson(response, url);
    if (!isLyricsResponse(body)) {
      throw new RequestError("LRCLIB returned an invalid lyrics response", {
        status: response.status,
        url: this.safeUrl(url)
      });
    }
    return body;
  }
  /** Gets plain lyrics parsed into individual lines. */
  async getUnsynced(info, options) {
    try {
      const body = await this.findLyrics(info, options);
      if (body.instrumental) return [{ text: "[Instrumental]" }];
      if (!body.plainLyrics) return null;
      return parseLocalLyrics(body.plainLyrics).unsynced;
    } catch (error) {
      if (error instanceof NotFoundError) return null;
      throw error;
    }
  }
  /** Gets synchronized lyrics parsed into lines with start times in seconds. */
  async getSynced(info, options) {
    try {
      const body = await this.findLyrics(info, options);
      if (body.instrumental) return [{ text: "[Instrumental]" }];
      if (!body.syncedLyrics) return null;
      return parseLocalLyrics(body.syncedLyrics).synced;
    } catch (error) {
      if (error instanceof NotFoundError) return null;
      throw error;
    }
  }
  /** Requests the proof-of-work challenge used by LRCLIB publishing clients. */
  async requestChallenge(options) {
    const url = this.createUrl("request-challenge");
    const headers = new Headers(options?.headers);
    headers.set("Accept", "application/json");
    const response = await this.request(url, {
      ...options,
      method: "POST",
      headers
    });
    if (!response.ok) throw this.httpError(response, url);
    const body = await this.readJson(response, url);
    if (!isRecord(body) || typeof body.prefix !== "string" || !body.prefix || typeof body.target !== "string" || !body.target) {
      throw new RequestError("LRCLIB returned an invalid challenge response", {
        status: response.status,
        url: this.safeUrl(url)
      });
    }
    return { prefix: body.prefix, target: body.target };
  }
  /**
   * Publishes lyrics with the token supplied in `ClientOptions.key`.
   * Redirects are rejected so the token cannot be forwarded to another origin.
   */
  async publishLyrics(info, options) {
    if (!this.publishToken) throw new KeyError();
    validateText(info.trackName, "trackName");
    validateText(info.artistName, "artistName");
    validateText(info.albumName, "albumName");
    if (!info.plainLyrics?.trim() && !info.syncedLyrics?.trim()) {
      throw new TypeError("At least one lyrics field must not be empty");
    }
    const headers = new Headers(options?.headers);
    headers.set("Accept", "application/json");
    headers.set("Content-Type", "application/json");
    headers.set("X-Publish-Token", this.publishToken);
    const url = this.createUrl("publish");
    const response = await this.request(url, {
      ...options,
      method: "POST",
      headers,
      body: JSON.stringify({
        track_name: info.trackName,
        artist_name: info.artistName,
        album_name: info.albumName,
        duration: durationInSeconds(info.duration),
        plain_lyrics: info.plainLyrics ?? "",
        synced_lyrics: info.syncedLyrics ?? ""
      }),
      redirect: "error"
    });
    if (response.status !== 201) throw this.httpError(response, url);
    return await response.text();
  }
};

export { Client, KeyError, NoResultError, NotFoundError, RequestError, parseLocalLyrics, parseTime };
//# sourceMappingURL=index.mjs.map
//# sourceMappingURL=index.mjs.map