mirror of
https://github.com/Kirazul/Verome-API.git
synced 2026-03-08 08:15:20 +00:00
1098 lines
38 KiB
TypeScript
1098 lines
38 KiB
TypeScript
/**
|
|
* Music API Library for Deno
|
|
* Contains all the core functionality for YouTube Music, YouTube Search, JioSaavn, and Last.fm
|
|
*/
|
|
|
|
// ============ YOUTUBE MUSIC API ============
|
|
|
|
export class YTMusic {
|
|
private baseURL: string;
|
|
private apiKey = "AIzaSyC9XL3ZjWjXClIX1FmUxJq--EohcD4_oSs";
|
|
private context: any;
|
|
|
|
constructor() {
|
|
this.baseURL = "https://music.youtube.com/youtubei/v1";
|
|
this.context = {
|
|
client: {
|
|
hl: "en",
|
|
gl: "US",
|
|
clientName: "WEB_REMIX",
|
|
clientVersion: "1.20251015.03.00",
|
|
platform: "DESKTOP",
|
|
},
|
|
};
|
|
}
|
|
|
|
async search(query: string, filter?: string, continuationToken?: string, ignoreSpelling = false) {
|
|
const params: any = continuationToken
|
|
? { continuation: continuationToken }
|
|
: { query, params: this.getFilterParams(filter) };
|
|
|
|
const data = await this.makeRequest("search", params);
|
|
return this.parseSearchResults(data);
|
|
}
|
|
|
|
async getSearchSuggestions(query: string): Promise<string[]> {
|
|
const data = await this.makeRequest("music/get_search_suggestions", { input: query });
|
|
return this.parseSuggestions(data);
|
|
}
|
|
|
|
async getSong(videoId: string) {
|
|
const data = await this.makeRequest("player", { videoId });
|
|
const details = data?.videoDetails || {};
|
|
return {
|
|
videoId: details.videoId,
|
|
title: details.title,
|
|
author: details.author,
|
|
lengthSeconds: details.lengthSeconds,
|
|
thumbnail: details.thumbnail?.thumbnails?.[0]?.url,
|
|
};
|
|
}
|
|
|
|
async getAlbum(browseId: string) {
|
|
const data = await this.makeRequest("browse", { browseId });
|
|
const header = data?.header?.musicDetailHeaderRenderer || data?.header?.musicImmersiveHeaderRenderer || {};
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
return {
|
|
title: header.title?.runs?.[0]?.text,
|
|
artist: header.subtitle?.runs?.[0]?.text,
|
|
thumbnail: header.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url,
|
|
tracks: this.parseTracksFromContents(contents),
|
|
};
|
|
}
|
|
|
|
async getArtist(browseId: string) {
|
|
const data = await this.makeRequest("browse", { browseId });
|
|
const header = data?.header?.musicImmersiveHeaderRenderer || data?.header?.musicVisualHeaderRenderer || {};
|
|
|
|
return {
|
|
name: header.title?.runs?.[0]?.text,
|
|
description: header.description?.runs?.[0]?.text,
|
|
thumbnail: header.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url,
|
|
};
|
|
}
|
|
|
|
async getArtistSummary(artistId: string, country = "US") {
|
|
const url = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false";
|
|
const body = {
|
|
browseId: artistId,
|
|
context: { client: { ...this.context.client, gl: country } },
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await response.json();
|
|
|
|
const header = data?.header?.musicImmersiveHeaderRenderer || data?.header?.musicVisualHeaderRenderer;
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
// Find top songs playlist ID
|
|
let playlistId = null;
|
|
for (const item of contents) {
|
|
if (item.musicShelfRenderer?.title?.runs?.[0]?.text === "Top songs") {
|
|
playlistId = item.musicShelfRenderer.contents?.[0]?.musicResponsiveListItemRenderer?.flexColumns?.[0]
|
|
?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.playlistId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find recommended artists
|
|
let recommendedArtists = null;
|
|
for (const item of contents) {
|
|
const headerTitle = item.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.[0]?.text;
|
|
if (headerTitle === "Fans might also like") {
|
|
recommendedArtists = (item.musicCarouselShelfRenderer.contents || []).map((it: any) => ({
|
|
name: it.musicTwoRowItemRenderer?.title?.runs?.[0]?.text,
|
|
browseId: it.musicTwoRowItemRenderer?.navigationEndpoint?.browseEndpoint?.browseId,
|
|
thumbnail: it.musicTwoRowItemRenderer?.thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url,
|
|
}));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
artistName: header?.title?.runs?.[0]?.text,
|
|
artistAvatar: header?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url,
|
|
playlistId,
|
|
recommendedArtists,
|
|
};
|
|
}
|
|
|
|
async getPlaylist(playlistId: string) {
|
|
const data = await this.makeRequest("browse", { browseId: `VL${playlistId.replace(/^VL/, "")}` });
|
|
const header = data?.header?.musicDetailHeaderRenderer || {};
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
return {
|
|
title: header.title?.runs?.[0]?.text,
|
|
author: header.subtitle?.runs?.[0]?.text,
|
|
thumbnail: header.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url,
|
|
tracks: this.parseTracksFromContents(contents),
|
|
};
|
|
}
|
|
|
|
async getCharts(country?: string) {
|
|
const data = await this.makeRequest("browse", {
|
|
browseId: "FEmusic_charts",
|
|
formData: { selectedValues: [country || "US"] },
|
|
});
|
|
return this.parseChartsData(data);
|
|
}
|
|
|
|
async getMoodCategories() {
|
|
const data = await this.makeRequest("browse", { browseId: "FEmusic_moods_and_genres" });
|
|
return this.parseMoodsData(data);
|
|
}
|
|
|
|
async getMoodPlaylists(categoryId: string) {
|
|
const data = await this.makeRequest("browse", { browseId: categoryId });
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
const playlists: any[] = [];
|
|
for (const section of contents) {
|
|
const items = section.musicShelfRenderer?.contents || [];
|
|
for (const item of items) {
|
|
const parsed = this.parseMusicItem(item.musicResponsiveListItemRenderer);
|
|
if (parsed) playlists.push(parsed);
|
|
}
|
|
}
|
|
return playlists;
|
|
}
|
|
|
|
async getWatchPlaylist(videoId?: string, playlistId?: string, radio = false, shuffle = false, limit = 25) {
|
|
const data = await this.makeRequest("next", { videoId, playlistId, radio, shuffle });
|
|
const contents = data?.contents?.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer?.watchNextTabbedResultsRenderer
|
|
?.tabs?.[0]?.tabRenderer?.content?.musicQueueRenderer?.content?.playlistPanelRenderer?.contents || [];
|
|
|
|
const tracks = contents.map((item: any) => {
|
|
const video = item.playlistPanelVideoRenderer;
|
|
if (!video) return null;
|
|
return {
|
|
videoId: video.videoId,
|
|
title: video.title?.runs?.[0]?.text,
|
|
author: video.shortBylineText?.runs?.[0]?.text,
|
|
thumbnail: video.thumbnail?.thumbnails?.[0]?.url,
|
|
};
|
|
}).filter(Boolean);
|
|
|
|
return { tracks: tracks.slice(0, limit) };
|
|
}
|
|
|
|
async getRelated(videoId: string) {
|
|
// Use YouTube's next endpoint for related videos
|
|
const url = `https://www.youtube.com/youtubei/v1/next?key=${this.apiKey}`;
|
|
const body = {
|
|
videoId,
|
|
context: { client: { clientName: "WEB", clientVersion: "2.20251013.01.00" } },
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await response.json();
|
|
|
|
const secondaryResults = data?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results || [];
|
|
|
|
return secondaryResults
|
|
.filter((item: any) => item.compactVideoRenderer)
|
|
.map((item: any) => {
|
|
const video = item.compactVideoRenderer;
|
|
const durationText = video.lengthText?.simpleText || "";
|
|
let durationSeconds = 0;
|
|
if (durationText) {
|
|
const parts = durationText.split(":").map((p: string) => parseInt(p) || 0);
|
|
if (parts.length === 2) durationSeconds = parts[0] * 60 + parts[1];
|
|
else if (parts.length === 3) durationSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
}
|
|
|
|
return {
|
|
videoId: video.videoId,
|
|
title: video.title?.simpleText || video.title?.runs?.[0]?.text,
|
|
artist: video.shortBylineText?.runs?.[0]?.text,
|
|
thumbnail: video.thumbnail?.thumbnails?.[0]?.url,
|
|
duration: durationText,
|
|
duration_seconds: durationSeconds,
|
|
isShort: durationSeconds > 0 && durationSeconds <= 60,
|
|
};
|
|
})
|
|
.filter((v: any) => v.videoId && !v.isShort)
|
|
.slice(0, 20);
|
|
}
|
|
|
|
private async makeRequest(endpoint: string, params: any) {
|
|
const url = `${this.baseURL}/${endpoint}?key=${this.apiKey}`;
|
|
const body = { context: this.context, ...params };
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
private getFilterParams(filter?: string): string {
|
|
const filterMap: Record<string, string> = {
|
|
songs: "Eg-KAQwIARAAGAAgACgAMABqChAEEAUQAxAKEAk%3D",
|
|
videos: "Eg-KAQwIABABGAAgACgAMABqChAEEAUQAxAKEAk%3D",
|
|
albums: "Eg-KAQwIABAAGAEgACgAMABqChAEEAUQAxAKEAk%3D",
|
|
artists: "EgWKAQIgAWoKEAMQBBAJEAoQBQ%3D%3D",
|
|
playlists: "Eg-KAQwIABAAGAAgACgBMABqChAEEAUQAxAKEAk%3D",
|
|
};
|
|
return filterMap[filter || ""] || filterMap.songs;
|
|
}
|
|
|
|
private parseSearchResults(data: any) {
|
|
const results: any[] = [];
|
|
let continuationToken: string | null = null;
|
|
|
|
// Handle continuation
|
|
const actions = data?.onResponseReceivedCommands || [];
|
|
for (const action of actions) {
|
|
const items = action?.appendContinuationItemsAction?.continuationItems || [];
|
|
for (const entry of items) {
|
|
if (entry.musicShelfRenderer || entry.musicShelfContinuation) {
|
|
const shelf = entry.musicShelfRenderer || entry.musicShelfContinuation;
|
|
for (const item of shelf.contents || []) {
|
|
const parsed = this.parseMusicItem(item.musicResponsiveListItemRenderer);
|
|
if (parsed) results.push(parsed);
|
|
}
|
|
continuationToken = shelf.continuations?.[0]?.nextContinuationData?.continuation || continuationToken;
|
|
}
|
|
if (entry.continuationItemRenderer) {
|
|
continuationToken = entry.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token || continuationToken;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle initial results
|
|
if (results.length === 0) {
|
|
const sections = data?.contents?.tabbedSearchResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
for (const section of sections) {
|
|
if (section.musicShelfRenderer) {
|
|
for (const item of section.musicShelfRenderer.contents || []) {
|
|
const parsed = this.parseMusicItem(item.musicResponsiveListItemRenderer);
|
|
if (parsed) results.push(parsed);
|
|
}
|
|
continuationToken = section.musicShelfRenderer.continuations?.[0]?.nextContinuationData?.continuation || continuationToken;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { results, continuationToken };
|
|
}
|
|
|
|
private parseSuggestions(data: any): string[] {
|
|
const suggestions: string[] = [];
|
|
const contents = data?.contents?.[0]?.searchSuggestionsSectionRenderer?.contents || data?.contents || [];
|
|
|
|
for (const content of contents) {
|
|
const runs = content?.searchSuggestionRenderer?.suggestion?.runs || [];
|
|
const text = runs.map((r: any) => r.text).join("");
|
|
if (text) suggestions.push(text);
|
|
}
|
|
return suggestions;
|
|
}
|
|
|
|
private parseMusicItem(item: any) {
|
|
if (!item) return null;
|
|
|
|
const title = item.flexColumns?.[0]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.[0]?.text;
|
|
const thumbnail = item.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.[0]?.url;
|
|
const videoId = item.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer
|
|
?.playNavigationEndpoint?.watchEndpoint?.videoId;
|
|
const browseId = item.navigationEndpoint?.browseEndpoint?.browseId;
|
|
|
|
const subtitle = item.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
|
|
const artists = subtitle
|
|
.filter((r: any) => r.navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
|
|
?.browseEndpointContextMusicConfig?.pageType === "MUSIC_PAGE_TYPE_ARTIST")
|
|
.map((r: any) => ({ name: r.text, id: r.navigationEndpoint?.browseEndpoint?.browseId }));
|
|
|
|
const duration = item.fixedColumns?.[0]?.musicResponsiveListItemFixedColumnRenderer?.text?.runs?.[0]?.text;
|
|
|
|
return {
|
|
title,
|
|
thumbnails: [{ url: thumbnail }],
|
|
videoId,
|
|
browseId,
|
|
artists,
|
|
duration,
|
|
resultType: videoId ? "song" : browseId?.startsWith("UC") ? "artist" : "album",
|
|
};
|
|
}
|
|
|
|
private parseTracksFromContents(contents: any[]): any[] {
|
|
const tracks: any[] = [];
|
|
for (const section of contents) {
|
|
const items = section.musicShelfRenderer?.contents || [];
|
|
for (const item of items) {
|
|
const parsed = this.parseMusicItem(item.musicResponsiveListItemRenderer);
|
|
if (parsed) tracks.push(parsed);
|
|
}
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
private parseChartsData(data: any) {
|
|
const results: any[] = [];
|
|
|
|
// Try different response structures
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
for (const section of contents) {
|
|
// Handle musicCarouselShelfRenderer (common for charts)
|
|
if (section.musicCarouselShelfRenderer) {
|
|
const title = section.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.[0]?.text;
|
|
const items = (section.musicCarouselShelfRenderer?.contents || []).map((item: any) => {
|
|
const renderer = item.musicTwoRowItemRenderer || item.musicResponsiveListItemRenderer;
|
|
return this.parseTwoRowItem(renderer);
|
|
}).filter(Boolean);
|
|
if (title && items.length) results.push({ title, items });
|
|
}
|
|
// Handle musicShelfRenderer
|
|
if (section.musicShelfRenderer) {
|
|
const title = section.musicShelfRenderer?.title?.runs?.[0]?.text;
|
|
const items = (section.musicShelfRenderer?.contents || []).map((item: any) =>
|
|
this.parseMusicItem(item.musicResponsiveListItemRenderer)
|
|
).filter(Boolean);
|
|
if (title && items.length) results.push({ title, items });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private parseMoodsData(data: any) {
|
|
const results: any[] = [];
|
|
const contents = data?.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
|
|
|
|
for (const section of contents) {
|
|
if (section.gridRenderer) {
|
|
const items = (section.gridRenderer?.items || []).map((item: any) => {
|
|
const nav = item.musicNavigationButtonRenderer;
|
|
if (!nav) return null;
|
|
return {
|
|
title: nav.buttonText?.runs?.[0]?.text,
|
|
browseId: nav.clickCommand?.browseEndpoint?.browseId,
|
|
color: nav.solid?.leftStripeColor,
|
|
};
|
|
}).filter(Boolean);
|
|
if (items.length) results.push({ title: "Moods & Genres", items });
|
|
}
|
|
if (section.musicCarouselShelfRenderer) {
|
|
const title = section.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.[0]?.text;
|
|
const items = (section.musicCarouselShelfRenderer?.contents || []).map((item: any) => {
|
|
const nav = item.musicNavigationButtonRenderer;
|
|
if (!nav) return null;
|
|
return {
|
|
title: nav.buttonText?.runs?.[0]?.text,
|
|
browseId: nav.clickCommand?.browseEndpoint?.browseId,
|
|
color: nav.solid?.leftStripeColor,
|
|
};
|
|
}).filter(Boolean);
|
|
if (title && items.length) results.push({ title, items });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private parseTwoRowItem(item: any) {
|
|
if (!item) return null;
|
|
return {
|
|
title: item.title?.runs?.[0]?.text,
|
|
subtitle: item.subtitle?.runs?.map((r: any) => r.text).join(""),
|
|
thumbnails: item.thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails,
|
|
videoId: item.navigationEndpoint?.watchEndpoint?.videoId,
|
|
browseId: item.navigationEndpoint?.browseEndpoint?.browseId,
|
|
playlistId: item.navigationEndpoint?.watchEndpoint?.playlistId,
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
// ============ YOUTUBE SEARCH ============
|
|
|
|
export class YouTubeSearch {
|
|
private searchURL = "https://www.youtube.com/results";
|
|
private continuationURL = "https://www.youtube.com/youtubei/v1/search";
|
|
private suggestionsURL = "https://suggestqueries-clients6.youtube.com/complete/search";
|
|
private apiKey: string | null = null;
|
|
private clientVersion: string | null = null;
|
|
|
|
async searchVideos(query: string | null, continuationToken?: string) {
|
|
if (continuationToken) {
|
|
return this.fetchContinuation(continuationToken, "video");
|
|
}
|
|
if (!query) throw new Error("Query is required for initial search");
|
|
|
|
const response = await fetch(`${this.searchURL}?search_query=${encodeURIComponent(query)}&sp=EgIQAQ%253D%253D`);
|
|
const html = await response.text();
|
|
this.extractAPIConfig(html);
|
|
return this.parseVideoResults(html);
|
|
}
|
|
|
|
async searchChannels(query: string | null, continuationToken?: string) {
|
|
if (continuationToken) {
|
|
return this.fetchContinuation(continuationToken, "channel");
|
|
}
|
|
if (!query) throw new Error("Query is required for initial search");
|
|
|
|
const response = await fetch(`${this.searchURL}?search_query=${encodeURIComponent(query)}&sp=EgIQAg%253D%253D`);
|
|
const html = await response.text();
|
|
this.extractAPIConfig(html);
|
|
return this.parseChannelResults(html);
|
|
}
|
|
|
|
async searchPlaylists(query: string | null, continuationToken?: string) {
|
|
if (continuationToken) {
|
|
return this.fetchContinuation(continuationToken, "playlist");
|
|
}
|
|
if (!query) throw new Error("Query is required for initial search");
|
|
|
|
const response = await fetch(`${this.searchURL}?search_query=${encodeURIComponent(query)}&sp=EgIQAw%253D%253D`);
|
|
const html = await response.text();
|
|
this.extractAPIConfig(html);
|
|
return this.parsePlaylistResults(html);
|
|
}
|
|
|
|
async getSuggestions(query: string): Promise<string[]> {
|
|
try {
|
|
const url = `${this.suggestionsURL}?ds=yt&client=youtube&q=${encodeURIComponent(query)}`;
|
|
const response = await fetch(url);
|
|
const text = await response.text();
|
|
|
|
// Parse JSONP response
|
|
const start = text.indexOf("(");
|
|
const end = text.lastIndexOf(")");
|
|
if (start === -1 || end === -1) return this.getStaticSuggestions(query);
|
|
|
|
const json = JSON.parse(text.slice(start + 1, end));
|
|
return (json[1] || []).map((item: any) => Array.isArray(item) ? item[0] : item).slice(0, 10);
|
|
} catch {
|
|
return this.getStaticSuggestions(query);
|
|
}
|
|
}
|
|
|
|
private extractAPIConfig(html: string) {
|
|
const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
|
|
const clientVersionMatch = html.match(/"clientVersion":"([^"]+)"/);
|
|
if (apiKeyMatch) this.apiKey = apiKeyMatch[1];
|
|
if (clientVersionMatch) this.clientVersion = clientVersionMatch[1];
|
|
}
|
|
|
|
private async fetchContinuation(token: string, type: string) {
|
|
if (!this.apiKey) throw new Error("API key not initialized");
|
|
|
|
const response = await fetch(`${this.continuationURL}?key=${this.apiKey}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
continuation: token,
|
|
context: { client: { clientName: "WEB", clientVersion: this.clientVersion || "2.20231219.01.00" } },
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
return this.parseContinuationResults(data, type);
|
|
}
|
|
|
|
private parseVideoResults(html: string) {
|
|
const results: any[] = [];
|
|
let continuationToken: string | null = null;
|
|
|
|
const jsonMatch = html.match(/var ytInitialData = ({.+?});/);
|
|
if (!jsonMatch) return { results, continuationToken };
|
|
|
|
const data = JSON.parse(jsonMatch[1]);
|
|
const sections = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
const items = sections[0]?.itemSectionRenderer?.contents || [];
|
|
|
|
for (const item of items) {
|
|
if (item.videoRenderer) {
|
|
results.push(this.parseVideoRenderer(item.videoRenderer));
|
|
}
|
|
}
|
|
|
|
continuationToken = this.extractContinuationToken(data);
|
|
return { results, continuationToken };
|
|
}
|
|
|
|
private parseChannelResults(html: string) {
|
|
const results: any[] = [];
|
|
let continuationToken: string | null = null;
|
|
|
|
const jsonMatch = html.match(/var ytInitialData = ({.+?});/);
|
|
if (!jsonMatch) return { results, continuationToken };
|
|
|
|
const data = JSON.parse(jsonMatch[1]);
|
|
const sections = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
|
|
for (const section of sections) {
|
|
for (const item of section?.itemSectionRenderer?.contents || []) {
|
|
if (item.channelRenderer) {
|
|
results.push(this.parseChannelRenderer(item.channelRenderer));
|
|
}
|
|
}
|
|
}
|
|
|
|
continuationToken = this.extractContinuationToken(data);
|
|
return { results, continuationToken };
|
|
}
|
|
|
|
private parsePlaylistResults(html: string) {
|
|
const results: any[] = [];
|
|
let continuationToken: string | null = null;
|
|
|
|
const jsonMatch = html.match(/var ytInitialData = ({.+?});/);
|
|
if (!jsonMatch) return { results, continuationToken };
|
|
|
|
const data = JSON.parse(jsonMatch[1]);
|
|
const sections = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
|
|
for (const section of sections) {
|
|
for (const item of section?.itemSectionRenderer?.contents || []) {
|
|
if (item.playlistRenderer) {
|
|
results.push(this.parsePlaylistRenderer(item.playlistRenderer));
|
|
}
|
|
}
|
|
}
|
|
|
|
continuationToken = this.extractContinuationToken(data);
|
|
return { results, continuationToken };
|
|
}
|
|
|
|
private parseContinuationResults(data: any, type: string) {
|
|
const results: any[] = [];
|
|
let continuationToken: string | null = null;
|
|
|
|
const actions = data?.onResponseReceivedCommands || [];
|
|
for (const action of actions) {
|
|
const items = action?.appendContinuationItemsAction?.continuationItems || [];
|
|
for (const item of items) {
|
|
if (item.continuationItemRenderer) {
|
|
continuationToken = item.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
|
continue;
|
|
}
|
|
if (type === "video" && item.videoRenderer) {
|
|
results.push(this.parseVideoRenderer(item.videoRenderer));
|
|
} else if (type === "channel" && item.channelRenderer) {
|
|
results.push(this.parseChannelRenderer(item.channelRenderer));
|
|
} else if (type === "playlist" && item.playlistRenderer) {
|
|
results.push(this.parsePlaylistRenderer(item.playlistRenderer));
|
|
}
|
|
if (item.itemSectionRenderer?.contents) {
|
|
for (const inner of item.itemSectionRenderer.contents) {
|
|
if (type === "video" && inner.videoRenderer) results.push(this.parseVideoRenderer(inner.videoRenderer));
|
|
else if (type === "channel" && inner.channelRenderer) results.push(this.parseChannelRenderer(inner.channelRenderer));
|
|
else if (type === "playlist" && inner.playlistRenderer) results.push(this.parsePlaylistRenderer(inner.playlistRenderer));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { results, continuationToken };
|
|
}
|
|
|
|
private extractContinuationToken(data: any): string | null {
|
|
const sections = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
for (const section of sections) {
|
|
if (section.continuationItemRenderer) {
|
|
return section.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token || null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private parseVideoRenderer(v: any) {
|
|
return {
|
|
type: "video",
|
|
id: v.videoId,
|
|
title: v.title?.runs?.[0]?.text,
|
|
duration: v.lengthText?.simpleText,
|
|
channel: {
|
|
id: v.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
|
name: v.ownerText?.runs?.[0]?.text,
|
|
},
|
|
thumbnails: v.thumbnail?.thumbnails,
|
|
publishedTime: v.publishedTimeText?.simpleText,
|
|
viewCount: { text: v.viewCountText?.simpleText },
|
|
link: `https://www.youtube.com/watch?v=${v.videoId}`,
|
|
};
|
|
}
|
|
|
|
private parseChannelRenderer(c: any) {
|
|
return {
|
|
type: "channel",
|
|
channelId: c.channelId,
|
|
title: c.title?.simpleText,
|
|
thumbnail: c.thumbnail?.thumbnails?.[0]?.url,
|
|
subscriberCount: c.subscriberCountText?.simpleText,
|
|
videoCount: c.videoCountText?.simpleText,
|
|
url: `https://www.youtube.com/channel/${c.channelId}`,
|
|
};
|
|
}
|
|
|
|
private parsePlaylistRenderer(p: any) {
|
|
return {
|
|
type: "playlist",
|
|
playlistId: p.playlistId,
|
|
title: p.title?.simpleText,
|
|
thumbnail: p.thumbnails?.[0]?.thumbnails?.[0]?.url,
|
|
videoCount: p.videoCount,
|
|
author: p.shortBylineText?.runs?.[0]?.text,
|
|
url: `https://www.youtube.com/playlist?list=${p.playlistId}`,
|
|
};
|
|
}
|
|
|
|
private getStaticSuggestions(query: string): string[] {
|
|
return [query, `${query} video`, `${query} 2024`, `${query} tutorial`, `${query} song`];
|
|
}
|
|
}
|
|
|
|
|
|
// ============ LAST.FM ============
|
|
|
|
export const LastFM = {
|
|
API_KEY: "0867bcb6f36c879398969db682a7b69b",
|
|
|
|
async getSimilarTracks(title: string, artist: string, limit = "5") {
|
|
const url = `https://ws.audioscrobbler.com/2.0/?method=track.getsimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${this.API_KEY}&limit=${limit}&format=json`;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (data?.error) return { error: data.message || "Last.fm error" };
|
|
|
|
return (data?.similartracks?.track || [])
|
|
.map((t: any) => ({ title: t.name, artist: t?.artist?.name }))
|
|
.filter((t: any) => t.title && t.artist);
|
|
} catch {
|
|
return { error: "Failed to fetch similar tracks" };
|
|
}
|
|
},
|
|
};
|
|
|
|
// ============ STREAMING SOURCES ============
|
|
|
|
let instancesCache: any = null;
|
|
let instancesCacheTime = 0;
|
|
const CACHE_DURATION = 5 * 60 * 1000;
|
|
|
|
export async function getDynamicInstances() {
|
|
const now = Date.now();
|
|
if (instancesCache && (now - instancesCacheTime) < CACHE_DURATION) {
|
|
return instancesCache;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("https://raw.githubusercontent.com/n-ce/Uma/main/dynamic_instances.json");
|
|
instancesCache = await response.json();
|
|
instancesCacheTime = now;
|
|
return instancesCache;
|
|
} catch {
|
|
return {
|
|
piped: ["https://api.piped.private.coffee"],
|
|
invidious: ["https://invidious.nikkosphere.com", "https://yt.omada.cafe"],
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchFromPiped(videoId: string) {
|
|
const instances = await getDynamicInstances();
|
|
const pipedInstances = instances.piped || [];
|
|
|
|
for (const instance of pipedInstances) {
|
|
try {
|
|
const response = await fetch(`${instance}/streams/${videoId}`);
|
|
const data = await response.json();
|
|
|
|
if (data?.audioStreams?.length) {
|
|
return {
|
|
success: true,
|
|
instance,
|
|
streamingUrls: data.audioStreams.map((s: any) => ({
|
|
url: s.url,
|
|
quality: s.quality,
|
|
mimeType: s.mimeType,
|
|
bitrate: s.bitrate,
|
|
})),
|
|
metadata: {
|
|
id: videoId,
|
|
title: data.title,
|
|
uploader: data.uploader,
|
|
thumbnail: data.thumbnailUrl,
|
|
duration: data.duration,
|
|
views: data.views,
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { success: false, error: "No working Piped instances found" };
|
|
}
|
|
|
|
export async function fetchFromInvidious(videoId: string) {
|
|
const instances = await getDynamicInstances();
|
|
const invidiousInstances = instances.invidious || [];
|
|
|
|
for (const instance of invidiousInstances) {
|
|
try {
|
|
const response = await fetch(`${instance}/api/v1/videos/${videoId}`);
|
|
const data = await response.json();
|
|
|
|
if (data) {
|
|
const audioFormats = (data.adaptiveFormats || []).filter((f: any) =>
|
|
f.type?.includes("audio") || f.mimeType?.includes("audio")
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
instance,
|
|
streamingUrls: audioFormats.map((f: any) => ({
|
|
url: f.url,
|
|
bitrate: f.bitrate,
|
|
type: f.type,
|
|
audioQuality: f.audioQuality,
|
|
})),
|
|
metadata: {
|
|
id: videoId,
|
|
title: data.title,
|
|
author: data.author,
|
|
thumbnail: data.videoThumbnails?.[0]?.url,
|
|
lengthSeconds: data.lengthSeconds,
|
|
viewCount: data.viewCount,
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { success: false, error: "No working Invidious instances found" };
|
|
}
|
|
|
|
// ============ LYRICS (LRCLib - Free, No API Key) ============
|
|
|
|
export async function getLyrics(title: string, artist: string, duration?: number) {
|
|
try {
|
|
// Try exact match first
|
|
let url = `https://lrclib.net/api/get?track_name=${encodeURIComponent(title)}&artist_name=${encodeURIComponent(artist)}`;
|
|
if (duration) url += `&duration=${duration}`;
|
|
|
|
let response = await fetch(url);
|
|
let data = await response.json();
|
|
|
|
// If no exact match, try search
|
|
if (!data || data.statusCode === 404) {
|
|
const searchUrl = `https://lrclib.net/api/search?q=${encodeURIComponent(`${title} ${artist}`)}`;
|
|
response = await fetch(searchUrl);
|
|
const results = await response.json();
|
|
if (Array.isArray(results) && results.length > 0) {
|
|
data = results[0];
|
|
}
|
|
}
|
|
|
|
if (!data || data.statusCode) {
|
|
return { success: false, error: "Lyrics not found" };
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
trackName: data.trackName,
|
|
artistName: data.artistName,
|
|
albumName: data.albumName,
|
|
duration: data.duration,
|
|
plainLyrics: data.plainLyrics,
|
|
syncedLyrics: data.syncedLyrics, // LRC format with timestamps
|
|
};
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ TRENDING MUSIC (YouTube Music Search by Country) ============
|
|
|
|
export async function getTrendingMusic(country = "United States", ytmusic?: YTMusic) {
|
|
try {
|
|
if (ytmusic) {
|
|
const searchQueries = [
|
|
`${country}n music 2026`,
|
|
`${country}n songs`,
|
|
`${country}n hits`,
|
|
`popular ${country}n music`,
|
|
`new ${country}n songs 2026`,
|
|
];
|
|
|
|
const allTracks: any[] = [];
|
|
const seenIds = new Set<string>();
|
|
|
|
for (const query of searchQueries) {
|
|
if (allTracks.length >= 30) break;
|
|
|
|
const results = await ytmusic.search(query, "songs");
|
|
if (results.results) {
|
|
for (const t of results.results) {
|
|
if (t.videoId && !seenIds.has(t.videoId)) {
|
|
seenIds.add(t.videoId);
|
|
allTracks.push({
|
|
name: t.title,
|
|
artist: t.artists?.map((a: any) => a.name).join(", "),
|
|
videoId: t.videoId,
|
|
thumbnail: t.thumbnails?.[0]?.url,
|
|
duration: t.duration,
|
|
});
|
|
}
|
|
if (allTracks.length >= 30) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allTracks.length > 0) {
|
|
return {
|
|
success: true,
|
|
country: country,
|
|
tracks: allTracks,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { success: false, error: "Could not fetch trending" };
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ RADIO (Infinite Mix based on song) ============
|
|
|
|
export async function getRadio(videoId: string, ytmusic: YTMusic) {
|
|
try {
|
|
// Use YouTube Music's radio feature
|
|
const data = await ytmusic.getWatchPlaylist(videoId, undefined, true, false, 50);
|
|
|
|
if (data.tracks && data.tracks.length > 0) {
|
|
return {
|
|
success: true,
|
|
seedVideoId: videoId,
|
|
tracks: data.tracks,
|
|
};
|
|
}
|
|
|
|
return { success: false, error: "Could not generate radio" };
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ TOP ARTISTS BY COUNTRY (YouTube Music Search) ============
|
|
|
|
export async function getTopArtists(country?: string, limit = 20, ytmusic?: YTMusic) {
|
|
try {
|
|
if (country && ytmusic) {
|
|
// More specific search queries for artists FROM that country
|
|
const searchQueries = [
|
|
`${country}n artist`, // Tunisian artist
|
|
`${country}n singer`,
|
|
`${country}n rapper`,
|
|
`${country}n musician`,
|
|
`artist from ${country}`,
|
|
`singer from ${country}`,
|
|
];
|
|
|
|
const allArtists: any[] = [];
|
|
const seenIds = new Set<string>();
|
|
|
|
for (const query of searchQueries) {
|
|
if (allArtists.length >= limit) break;
|
|
|
|
const results = await ytmusic.search(query, "artists");
|
|
if (results.results) {
|
|
for (const a of results.results) {
|
|
if (a.browseId && !seenIds.has(a.browseId)) {
|
|
seenIds.add(a.browseId);
|
|
allArtists.push({
|
|
name: a.title,
|
|
browseId: a.browseId,
|
|
thumbnail: a.thumbnails?.[0]?.url,
|
|
});
|
|
}
|
|
if (allArtists.length >= limit) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allArtists.length > 0) {
|
|
return {
|
|
success: true,
|
|
country: country,
|
|
artists: allArtists.slice(0, limit),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Fallback to Last.fm global charts
|
|
const url = `https://ws.audioscrobbler.com/2.0/?method=chart.gettopartists&api_key=${LastFM.API_KEY}&limit=${limit}&format=json`;
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
const artists = data?.artists?.artist || [];
|
|
|
|
return {
|
|
success: true,
|
|
country: "Global",
|
|
artists: artists.map((a: any) => ({
|
|
name: a.name,
|
|
playcount: a.playcount,
|
|
listeners: a.listeners,
|
|
url: a.url,
|
|
image: a.image?.find((i: any) => i.size === "large")?.["#text"],
|
|
})),
|
|
};
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ TOP TRACKS BY COUNTRY (YouTube Music Search) ============
|
|
|
|
export async function getTopTracks(country?: string, limit = 20, ytmusic?: YTMusic) {
|
|
try {
|
|
if (country && ytmusic) {
|
|
// More specific search queries for music FROM that country
|
|
const searchQueries = [
|
|
`${country}n music`, // Tunisian music
|
|
`${country}n songs`,
|
|
`${country}n rap`,
|
|
`${country}n hits 2026`,
|
|
`music from ${country}`,
|
|
`songs from ${country}`,
|
|
];
|
|
|
|
const allTracks: any[] = [];
|
|
const seenIds = new Set<string>();
|
|
|
|
for (const query of searchQueries) {
|
|
if (allTracks.length >= limit) break;
|
|
|
|
const results = await ytmusic.search(query, "songs");
|
|
if (results.results) {
|
|
for (const t of results.results) {
|
|
if (t.videoId && !seenIds.has(t.videoId)) {
|
|
seenIds.add(t.videoId);
|
|
allTracks.push({
|
|
name: t.title,
|
|
artist: t.artists?.map((a: any) => a.name).join(", "),
|
|
videoId: t.videoId,
|
|
thumbnail: t.thumbnails?.[0]?.url,
|
|
duration: t.duration,
|
|
});
|
|
}
|
|
if (allTracks.length >= limit) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allTracks.length > 0) {
|
|
return {
|
|
success: true,
|
|
country: country,
|
|
tracks: allTracks.slice(0, limit),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Fallback to Last.fm global charts
|
|
const url = `https://ws.audioscrobbler.com/2.0/?method=chart.gettoptracks&api_key=${LastFM.API_KEY}&limit=${limit}&format=json`;
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
const tracks = data?.tracks?.track || [];
|
|
|
|
return {
|
|
success: true,
|
|
country: "Global",
|
|
tracks: tracks.map((t: any) => ({
|
|
name: t.name,
|
|
artist: t.artist?.name,
|
|
playcount: t.playcount,
|
|
listeners: t.listeners,
|
|
url: t.url,
|
|
})),
|
|
};
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ ARTIST INFO (Last.fm) ============
|
|
|
|
export async function getArtistInfo(artist: string) {
|
|
try {
|
|
const url = `https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist=${encodeURIComponent(artist)}&api_key=${LastFM.API_KEY}&format=json`;
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (data?.error) {
|
|
return { success: false, error: data.message };
|
|
}
|
|
|
|
const a = data?.artist;
|
|
return {
|
|
success: true,
|
|
name: a?.name,
|
|
bio: a?.bio?.summary?.replace(/<[^>]*>/g, ""), // Strip HTML
|
|
tags: a?.tags?.tag?.map((t: any) => t.name) || [],
|
|
similar: a?.similar?.artist?.map((s: any) => s.name) || [],
|
|
stats: {
|
|
listeners: a?.stats?.listeners,
|
|
playcount: a?.stats?.playcount,
|
|
},
|
|
image: a?.image?.find((i: any) => i.size === "large")?.["#text"],
|
|
};
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
// ============ TRACK INFO (Last.fm) ============
|
|
|
|
export async function getTrackInfo(title: string, artist: string) {
|
|
try {
|
|
const url = `https://ws.audioscrobbler.com/2.0/?method=track.getInfo&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${LastFM.API_KEY}&format=json`;
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (data?.error) {
|
|
return { success: false, error: data.message };
|
|
}
|
|
|
|
const t = data?.track;
|
|
return {
|
|
success: true,
|
|
name: t?.name,
|
|
artist: t?.artist?.name,
|
|
album: t?.album?.title,
|
|
duration: t?.duration,
|
|
listeners: t?.listeners,
|
|
playcount: t?.playcount,
|
|
tags: t?.toptags?.tag?.map((tag: any) => tag.name) || [],
|
|
wiki: t?.wiki?.summary?.replace(/<[^>]*>/g, ""),
|
|
};
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|