src/voice/musicSearch.ts

export type MusicPlatform = "youtube" | "soundcloud";

export type MusicSearchResult = { id: string; title: string; artist: string; platform: MusicPlatform; streamUrl: string | null; durationSeconds: number | null; thumbnailUrl: string | null; externalUrl: string; };

type MusicSearchOptions = { platform?: MusicPlatform | "auto"; limit?: number; };

type MusicSearchResponse = { ok: boolean; query: string; results: MusicSearchResult[]; error: string | null; };

const YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3"; const SEARCH_LIMIT_DEFAULT = 10; const SEARCH_LIMIT_MAX = 25;

function normalizeQuery(value = ""): string { return String(value || "").replace(/\s+/g, " ").trim(); }

function clampLimit(value = 0): number { const n = Math.floor(Number(value) || 0); return Math.max(1, Math.min(n, SEARCH_LIMIT_MAX)); }

function calculateFuzzyScore(query: string, title: string, artist: string): number { const q = normalizeQuery(query).toLowerCase(); const t = normalizeQuery(title).toLowerCase(); const a = normalizeQuery(artist).toLowerCase();

if (t === q || a === q) return 1.0; if (t.includes(q) || a.includes(q)) return 0.9; if (t.startsWith(q) || a.startsWith(q)) return 0.85;

let score = 0; const qWords = q.split(" ").filter(Boolean); const tWords = t.split(" ").filter(Boolean); const aWords = a.split(" ").filter(Boolean);

for (const qw of qWords) { if (tWords.some((tw) => tw.includes(qw) || qw.includes(tw))) score += 0.3; if (aWords.some((aw) => aw.includes(qw) || qw.includes(aw))) score += 0.3; }

return Math.min(1.0, score); }

export class MusicSearchProvider { youtubeApiKey: string; soundcloudClientId: string;

constructor({ youtubeApiKey = "", soundcloudClientId = "" } = {}) { this.youtubeApiKey = String(youtubeApiKey || "").trim(); this.soundcloudClientId = String(soundcloudClientId || "").trim(); }

isConfigured(): boolean { return Boolean(this.youtubeApiKey || this.soundcloudClientId); }

async search(query: string, options: MusicSearchOptions = {}): Promise { const normalizedQuery = normalizeQuery(query); if (!normalizedQuery) { return { ok: false, query: "", results: [], error: "empty query" }; }

const platform = options.platform || "auto";
const limit = clampLimit(options.limit || SEARCH_LIMIT_DEFAULT);

const searches: Promise<MusicSearchResponse>[] = [];

if (platform === "auto" || platform === "youtube") {
  searches.push(this.searchYoutube(normalizedQuery, limit));
}
if (platform === "auto" || platform === "soundcloud") {
  searches.push(this.searchSoundcloud(normalizedQuery, limit));
}

const results = await Promise.all(searches);
const allResults = results.flatMap((r) => r.results);

allResults.sort((a, b) => {
  const scoreA = calculateFuzzyScore(normalizedQuery, a.title, a.artist);
  const scoreB = calculateFuzzyScore(normalizedQuery, b.title, b.artist);
  return scoreB - scoreA;
});

return {
  ok: true,
  query: normalizedQuery,
  results: allResults.slice(0, limit),
  error: null
};

}

private async searchYoutube(query: string, limit: number): Promise { if (!this.youtubeApiKey) { return { ok: true, query, results: [], error: null }; }

try {
  const params = new URLSearchParams({
    part: "snippet",
    q: query,
    type: "video",
    videoCategoryId: "10",
    maxResults: String(limit),
    key: this.youtubeApiKey
  });

  const response = await fetch(`${YOUTUBE_API_BASE}/search?${params}`);
  if (!response.ok) {
    return { ok: true, query, results: [], error: `youtube api error: ${response.status}` };
  }

  const data = await response.json().catch(() => null);
  if (!data?.items) {
    return { ok: true, query, results: [], error: null };
  }

  const results: MusicSearchResult[] = data.items
    .filter((item: Record<string, unknown>) => (item.id as Record<string, string>)?.videoId)
    .map((item: Record<string, unknown>) => {
      const snippet = item.snippet as Record<string, unknown>;
      const idObj = item.id as Record<string, string>;
      const videoId = idObj.videoId || "";
      const thumbnails = (snippet.thumbnails as Record<string, { url?: string }>) || {};
      return {
        id: `youtube:${videoId}`,
        title: String(snippet.title || "Unknown"),
        artist: String(snippet.channelTitle || "Unknown Artist"),
        platform: "youtube" as MusicPlatform,
        streamUrl: null,
        durationSeconds: null,
        thumbnailUrl: thumbnails.medium?.url || thumbnails.default?.url || null,
        externalUrl: `https://www.youtube.com/watch?v=${videoId}`
      };
    });

  return { ok: true, query, results, error: null };
} catch (error) {
  return { ok: true, query, results: [], error: String(error?.message || error) };
}

}

private async searchSoundcloud(query: string, limit: number): Promise { if (!this.soundcloudClientId) { return { ok: true, query, results: [], error: null }; }

try {
  const params = new URLSearchParams({
    q: query,
    client_id: this.soundcloudClientId,
    limit: String(limit),
    offset: "0"
  });

  const response = await fetch(`https://api.soundcloud.com/tracks?${params}`);
  if (!response.ok) {
    return { ok: true, query, results: [], error: `soundcloud api error: ${response.status}` };
  }

  const data = await response.json().catch(() => null);
  if (!Array.isArray(data)) {
    return { ok: true, query, results: [], error: null };
  }

  const results: MusicSearchResult[] = data
    .filter((track: Record<string, unknown>) => track.id && track.stream_url)
    .map((track: Record<string, unknown>) => {
      const permalinkUrl = String(track.permalink_url || "").trim();
      const normalizedExternalUrl = permalinkUrl
        ? /^https?:\/\//i.test(permalinkUrl)
          ? permalinkUrl
          : `https://soundcloud.com${permalinkUrl.startsWith("/") ? "" : "/"}${permalinkUrl}`
        : "";
      return {
        id: `soundcloud:${track.id}`,
        title: (track.title as string) || "Unknown",
        artist: ((track.user as Record<string, unknown>)?.username as string) || "Unknown Artist",
        platform: "soundcloud" as MusicPlatform,
        streamUrl: `${track.stream_url}?client_id=${this.soundcloudClientId}`,
        durationSeconds: track.duration ? Math.floor((track.duration as number) / 1000) : null,
        thumbnailUrl: (track.artwork_url as string) || null,
        externalUrl: normalizedExternalUrl
      };
    });

  return { ok: true, query, results, error: null };
} catch (error) {
  return { ok: true, query, results: [], error: String(error?.message || error) };
}

}

async resolveStreamUrl(result: MusicSearchResult): Promise<string | null> { if (result.streamUrl) return result.streamUrl;

if (result.platform === "youtube" && result.id.startsWith("youtube:")) {
  const videoId = result.id.replace("youtube:", "");
  return `https://www.youtube.com/watch?v=${videoId}`;
}

if (result.platform === "soundcloud" && result.id.startsWith("soundcloud:")) {
  return result.externalUrl;
}

return result.externalUrl;

}

formatDisambiguationMessage(results: MusicSearchResult[]): string { if (results.length === 0) return "No results found."; if (results.length === 1) { const r = results[0]; return Playing "${r.title}" by ${r.artist} on ${r.platform}; }

const display = results.slice(0, 5).map((r, i) => {
  const p = r.platform === "youtube" ? "YT" : "SC";
  return `${i + 1}. "${r.title}" by ${r.artist} (${p})`;
});

return `Which track did you mean?

${display.join(" ")}

Reply with the number (1-${Math.min(5, results.length)})`; } }

export function createMusicSearchProvider(appConfig: { youtubeApiKey?: string; soundcloudClientId?: string; }): MusicSearchProvider { return new MusicSearchProvider({ youtubeApiKey: appConfig?.youtubeApiKey, soundcloudClientId: appConfig?.soundcloudClientId }); }