commit 1ae246c7e41e13b014fdcde1d5f5d444267a3221 Author: Your Name Date: Sat Jan 10 14:52:59 2026 +0100 Initial commit - Virome API diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14bbb0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..e52b2d7 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +

+ Virome API +

+ +

Virome API

+ +

+ A music API built with Deno for searching, streaming, and exploring music data from YouTube Music, YouTube, and Last.fm. +

+ +--- + +## Overview + +Virome API provides a unified interface to access music data from multiple sources. It includes a built-in web UI for testing endpoints and playing music directly in the browser. + +## Features + +- Search songs, albums, artists, and playlists +- Get song lyrics with synced timestamps +- Stream audio from YouTube via Piped/Invidious proxies +- Generate radio mixes based on a song +- Get trending music and top artists by country +- Artist and track information from Last.fm +- Built-in music player with YouTube IFrame API + +## Installation + +### Requirements + +- Deno 1.40 or higher + +### Run Locally + +```bash +cd deno-music-api +deno run --allow-net --allow-env --allow-read mod.ts +``` + +The server starts at `http://localhost:8000` + +### Deploy to Deno Deploy + +1. Push the code to a GitHub repository +2. Go to https://dash.deno.com +3. Create a new project and link your repository +4. Set the entry point to `mod.ts` + +## API Endpoints + +### Search + +| Endpoint | Description | Parameters | +|----------|-------------|------------| +| GET /api/search | Search YouTube Music | q, filter (songs/albums/artists) | +| GET /api/yt_search | Search YouTube | q, filter (videos/channels/playlists) | +| GET /api/search/suggestions | Get search suggestions | q | + +### Content + +| Endpoint | Description | Parameters | +|----------|-------------|------------| +| GET /api/songs/:videoId | Get song details | videoId | +| GET /api/albums/:browseId | Get album tracks | browseId | +| GET /api/artists/:browseId | Get artist info | browseId | +| GET /api/playlists/:playlistId | Get playlist tracks | playlistId | + +### Discovery + +| Endpoint | Description | Parameters | +|----------|-------------|------------| +| GET /api/related/:videoId | Get related songs | videoId | +| GET /api/similar | Find similar tracks | title, artist | +| GET /api/trending | Trending music by country | country | +| GET /api/radio | Generate radio mix | videoId | +| GET /api/top/artists | Top artists by country | country, limit | +| GET /api/top/tracks | Top tracks by country | country, limit | + +### Info + +| Endpoint | Description | Parameters | +|----------|-------------|------------| +| GET /api/lyrics | Get song lyrics | title, artist | +| GET /api/artist/info | Artist bio and stats | artist | +| GET /api/track/info | Track details | title, artist | + +### Streaming + +| Endpoint | Description | Parameters | +|----------|-------------|------------| +| GET /api/stream | Get stream URLs | id | +| GET /api/watch_playlist | Get watch playlist | videoId or playlistId | +| GET /health | Health check | - | + +## Usage Examples + +### Search for songs + +``` +GET /api/search?q=Blinding%20Lights&filter=songs +``` + +### Get lyrics + +``` +GET /api/lyrics?title=Blinding%20Lights&artist=The%20Weeknd +``` + +### Get stream URLs + +``` +GET /api/stream?id=4NRXx6U8ABQ +``` + +### Get top artists from a country + +``` +GET /api/top/artists?country=Tunisia&limit=20 +``` + +### Generate radio from a song + +``` +GET /api/radio?videoId=4NRXx6U8ABQ +``` + +## Project Structure + +``` +deno-music-api/ + mod.ts - Main server and route handlers + lib.ts - API clients (YouTube Music, YouTube, Last.fm) + ui.ts - Web UI HTML template + deno.json - Deno configuration + assets/ + Logo.png - Project logo +``` + +## Data Sources + +- YouTube Music API (unofficial) +- YouTube Data (via Invidious/Piped) +- Last.fm API (artist info, similar tracks) +- LRCLib (lyrics) + +## License + +MIT + +## Disclaimer + +This is an unofficial API for educational purposes. It is not affiliated with YouTube, Google, or Last.fm. diff --git a/assets/Logo.png b/assets/Logo.png new file mode 100644 index 0000000..96c015f Binary files /dev/null and b/assets/Logo.png differ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..9b4aea2 --- /dev/null +++ b/deno.json @@ -0,0 +1,14 @@ +{ + "name": "music-api", + "version": "1.0.0", + "tasks": { + "start": "deno run --allow-net --allow-env mod.ts", + "dev": "deno run --watch --allow-net --allow-env mod.ts" + }, + "imports": { + "std/": "https://deno.land/std@0.208.0/" + }, + "compilerOptions": { + "strict": true + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..74bf7bb --- /dev/null +++ b/deno.lock @@ -0,0 +1,7 @@ +{ + "version": "5", + "remote": { + "https://deno.land/std@0.208.0/async/delay.ts": "a6142eb44cdd856b645086af2b811b1fcce08ec06bb7d50969e6a872ee9b8659", + "https://deno.land/std@0.208.0/http/server.ts": "f3cde6672e631d3e00785743cfa96bfed275618c0352c5ae84abbe5a2e0e4afc" + } +} diff --git a/lib.ts b/lib.ts new file mode 100644 index 0000000..72eb38d --- /dev/null +++ b/lib.ts @@ -0,0 +1,1097 @@ +/** + * 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 { + 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 = { + 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 { + 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(); + + 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(); + + 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(); + + 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) }; + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..c66d8b4 --- /dev/null +++ b/mod.ts @@ -0,0 +1,641 @@ +/** + * Virome API for Deno + * A consolidated YouTube Music, YouTube Search, JioSaavn, and Last.fm API + * + * Run with: deno run --allow-net --allow-env --allow-read mod.ts + * Or deploy to Deno Deploy + */ + +import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; +import { YTMusic, YouTubeSearch, LastFM, fetchFromPiped, fetchFromInvidious, getLyrics, getTrendingMusic, getRadio, getTopArtists, getTopTracks, getArtistInfo, getTrackInfo } from "./lib.ts"; +import { html as uiHtml } from "./ui.ts"; + +const PORT = parseInt(Deno.env.get("PORT") || "8000"); + +// Initialize clients +const ytmusic = new YTMusic(); +const youtubeSearch = new YouTubeSearch(); + +// CORS headers +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +// Helper functions +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); +} + +function error(message: string, status = 400): Response { + return json({ error: message }, status); +} + +// URL pattern matching +function matchRoute(pathname: string, pattern: string): Record | null { + const patternParts = pattern.split("/"); + const pathParts = pathname.split("/"); + + if (patternParts.length !== pathParts.length) return null; + + const params: Record = {}; + for (let i = 0; i < patternParts.length; i++) { + if (patternParts[i].startsWith(":")) { + params[patternParts[i].slice(1)] = pathParts[i]; + } else if (patternParts[i] !== pathParts[i]) { + return null; + } + } + return params; +} + +// Main request handler +async function handler(req: Request): Promise { + const url = new URL(req.url); + const { pathname, searchParams } = url; + + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Root - API Documentation UI + if (pathname === "/") { + return new Response(uiHtml, { headers: { "Content-Type": "text/html", ...corsHeaders } }); + } + + // Serve logo from assets + if (pathname === "/assets/logo.png" || pathname === "/assets/Logo.png") { + try { + const logoPath = new URL("./assets/Logo.png", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"); + const logo = await Deno.readFile(logoPath); + return new Response(logo, { + headers: { "Content-Type": "image/png", ...corsHeaders } + }); + } catch { + return new Response("Logo not found", { status: 404 }); + } + } + + // Favicon + if (pathname === "/favicon.ico") { + return new Response(null, { status: 204 }); + } + + // Health check + if (pathname === "/health") { + return json({ status: "ok" }); + } + + // ============ SEARCH ENDPOINTS ============ + + // YouTube Music Search + if (pathname === "/api/search") { + const query = searchParams.get("q"); + const filter = searchParams.get("filter") || undefined; + const continuationToken = searchParams.get("continuationToken") || undefined; + const ignoreSpelling = searchParams.get("ignore_spelling") === "true"; + + if (!query && !continuationToken) { + return error("Missing required query parameter 'q' or 'continuationToken'"); + } + + const results = await ytmusic.search(query || "", filter, continuationToken, ignoreSpelling); + return json({ query, filter, ...results }); + } + + // Search suggestions + if (pathname === "/api/search/suggestions") { + const query = searchParams.get("q"); + const music = searchParams.get("music"); + + if (!query) return error("Missing required query parameter 'q'"); + + if (music === "1") { + const suggestions = await ytmusic.getSearchSuggestions(query); + return json({ suggestions, source: "youtube_music" }); + } else { + const suggestions = await youtubeSearch.getSuggestions(query); + return json({ suggestions, source: "youtube" }); + } + } + + // YouTube Search + if (pathname === "/api/yt_search") { + const query = searchParams.get("q"); + const filter = searchParams.get("filter") || "all"; + const continuationToken = searchParams.get("continuationToken") || undefined; + + if (!query && !continuationToken) { + return error("Missing required query parameter 'q' or 'continuationToken'"); + } + + const results: unknown[] = []; + let nextToken: string | null = null; + + if (continuationToken) { + if (filter === "videos") { + const r = await youtubeSearch.searchVideos(null, continuationToken); + results.push(...r.results); + nextToken = r.continuationToken; + } else if (filter === "channels") { + const r = await youtubeSearch.searchChannels(null, continuationToken); + results.push(...r.results); + nextToken = r.continuationToken; + } else if (filter === "playlists") { + const r = await youtubeSearch.searchPlaylists(null, continuationToken); + results.push(...r.results); + nextToken = r.continuationToken; + } + } else if (query) { + if (filter === "videos" || filter === "all") { + const r = await youtubeSearch.searchVideos(query); + results.push(...r.results); + nextToken = r.continuationToken; + } + if (filter === "channels" || filter === "all") { + const r = await youtubeSearch.searchChannels(query); + results.push(...r.results); + if (!nextToken) nextToken = r.continuationToken; + } + if (filter === "playlists" || filter === "all") { + const r = await youtubeSearch.searchPlaylists(query); + results.push(...r.results); + if (!nextToken) nextToken = r.continuationToken; + } + } + + return json({ filter, query, results, continuationToken: nextToken }); + } + + // ============ ENTITY ENDPOINTS ============ + + // Get song details + let params = matchRoute(pathname, "/api/songs/:videoId"); + if (params) { + const data = await ytmusic.getSong(params.videoId); + return json(data); + } + + // Get album details + params = matchRoute(pathname, "/api/albums/:browseId"); + if (params) { + const data = await ytmusic.getAlbum(params.browseId); + return json(data); + } + + params = matchRoute(pathname, "/api/album/:id"); + if (params) { + const data = await ytmusic.getAlbum(params.id); + return json(data); + } + + // Get artist details (skip if it's /api/artist/info) + params = matchRoute(pathname, "/api/artists/:browseId"); + if (params) { + const data = await ytmusic.getArtist(params.browseId); + return json(data); + } + + // Skip /api/artist/info - handled later by Last.fm endpoint + if (pathname !== "/api/artist/info") { + params = matchRoute(pathname, "/api/artist/:artistId"); + if (params) { + const country = searchParams.get("country") || "US"; + const data = await ytmusic.getArtistSummary(params.artistId, country); + return json(data); + } + } + + // Get playlist details + params = matchRoute(pathname, "/api/playlists/:playlistId"); + if (params) { + const data = await ytmusic.getPlaylist(params.playlistId); + return json(data); + } + + params = matchRoute(pathname, "/api/playlist/:id"); + if (params) { + const data = await ytmusic.getPlaylist(params.id); + return json(data); + } + + // Get related videos + params = matchRoute(pathname, "/api/related/:id"); + if (params) { + const data = await ytmusic.getRelated(params.id); + return json({ success: true, data }); + } + + // ============ EXPLORE ENDPOINTS ============ + + // Charts + if (pathname === "/api/charts") { + const country = searchParams.get("country") || undefined; + const data = await ytmusic.getCharts(country); + return json(data); + } + + // Moods + if (pathname === "/api/moods") { + const data = await ytmusic.getMoodCategories(); + return json(data); + } + + params = matchRoute(pathname, "/api/moods/:categoryId"); + if (params) { + const data = await ytmusic.getMoodPlaylists(params.categoryId); + return json(data); + } + + // Watch playlist + if (pathname === "/api/watch_playlist") { + const videoId = searchParams.get("videoId") || undefined; + const playlistId = searchParams.get("playlistId") || undefined; + const radio = searchParams.get("radio") === "true"; + const shuffle = searchParams.get("shuffle") === "true"; + const limit = parseInt(searchParams.get("limit") || "25"); + + if (!videoId && !playlistId) { + return error("Provide either videoId or playlistId"); + } + + const data = await ytmusic.getWatchPlaylist(videoId, playlistId, radio, shuffle, limit); + return json(data); + } + + // ============ STREAMING ENDPOINTS ============ + + // Find song + if (pathname === "/api/music/find") { + const name = searchParams.get("name"); + const artist = searchParams.get("artist"); + + if (!name || !artist) { + return error("Missing required parameters: name and artist are required"); + } + + const query = `${name} ${artist}`; + const searchResults = await ytmusic.search(query, "songs"); + + if (!searchResults.results?.length) { + return json({ success: false, error: "Song not found" }, 404); + } + + const normalize = (s: string) => s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); + const nName = normalize(name); + const artistsList = artist.split(",").map(a => normalize(a)); + + const bestMatch = searchResults.results.find((song: any) => { + const nSongName = normalize(song.title || ""); + const songArtists = (song.artists || []).map((a: any) => normalize(a.name || "")); + const titleMatch = nSongName.includes(nName) || nName.includes(nSongName); + const artistMatch = artistsList.some(a => songArtists.some((sa: string) => sa.includes(a) || a.includes(sa))); + return titleMatch && artistMatch; + }); + + if (bestMatch) { + return json({ success: true, data: bestMatch }); + } + return json({ success: false, error: "Song not found after filtering" }, 404); + } + + // Stream endpoint - multi-source + if (pathname === "/api/stream") { + const id = searchParams.get("id"); + + if (!id) { + return error("Missing required parameter: id"); + } + + // Try Piped first + const pipedResult = await fetchFromPiped(id); + if (pipedResult.success) { + return json({ + success: true, + service: "piped", + instance: pipedResult.instance, + streamingUrls: pipedResult.streamingUrls, + metadata: pipedResult.metadata, + requestedId: id, + timestamp: new Date().toISOString(), + }); + } + + // Try Invidious + const invidiousResult = await fetchFromInvidious(id); + if (invidiousResult.success) { + return json({ + success: true, + service: "invidious", + instance: invidiousResult.instance, + streamingUrls: invidiousResult.streamingUrls, + metadata: invidiousResult.metadata, + requestedId: id, + timestamp: new Date().toISOString(), + }); + } + + return json({ success: false, error: "No streaming data found from any source" }, 404); + } + + // ============ SIMILAR TRACKS ============ + + if (pathname === "/api/similar") { + const title = searchParams.get("title"); + const artist = searchParams.get("artist"); + const limit = searchParams.get("limit") || "5"; + + if (!title || !artist) { + return error("Missing title or artist parameter"); + } + + const similarTracks = await LastFM.getSimilarTracks(title, artist, limit); + if ("error" in similarTracks) { + return json({ error: similarTracks.error }, 500); + } + + const ytResults = await Promise.all( + similarTracks.map(async (t: any) => { + const r = await youtubeSearch.searchVideos(`${t.title} ${t.artist}`); + return r.results[0] || null; + }) + ); + + return json(ytResults.filter(Boolean)); + } + + // ============ FEED ENDPOINTS ============ + + // Audio proxy to bypass CORS + if (pathname === "/api/proxy") { + const audioUrl = searchParams.get("url"); + if (!audioUrl) { + return error("Missing url parameter"); + } + return proxyAudio(audioUrl); + } + + if (pathname === "/api/feed/unauthenticated" || pathname.startsWith("/api/feed/channels=")) { + let channelsParam = searchParams.get("channels"); + + if (pathname.startsWith("/api/feed/channels=")) { + channelsParam = pathname.replace("/api/feed/channels=", "").split("?")[0]; + } + + if (!channelsParam) { + return error("No valid channel IDs provided"); + } + + const channelIds = channelsParam.split(",").map(s => s.trim()).filter(Boolean); + const preview = searchParams.get("preview") === "1"; + + const results: any[] = []; + for (const channelId of channelIds) { + const items = await fetchChannelVideos(channelId, preview ? 5 : undefined); + results.push(...items); + } + + // Filter shorts and sort by upload date + const filtered = results + .filter(item => !item.isShort) + .sort((a, b) => Number(b.uploaded) - Number(a.uploaded)); + + return json(filtered); + } + + // ============ NEW FEATURES ============ + + // Lyrics + if (pathname === "/api/lyrics") { + const title = searchParams.get("title"); + const artist = searchParams.get("artist"); + const duration = searchParams.get("duration"); + + if (!title || !artist) { + return error("Missing required parameters: title and artist"); + } + + const result = await getLyrics(title, artist, duration ? parseInt(duration) : undefined); + return json(result); + } + + // Trending Music + if (pathname === "/api/trending") { + const country = searchParams.get("country") || "United States"; + const result = await getTrendingMusic(country, ytmusic); + return json(result); + } + + // Radio (infinite mix based on a song) + if (pathname === "/api/radio") { + const videoId = searchParams.get("videoId"); + if (!videoId) { + return error("Missing required parameter: videoId"); + } + const result = await getRadio(videoId, ytmusic); + return json(result); + } + + // Top Artists (by country using YouTube Music search) + if (pathname === "/api/top/artists") { + const country = searchParams.get("country") || undefined; + const limit = parseInt(searchParams.get("limit") || "20"); + const result = await getTopArtists(country, limit, ytmusic); + return json(result); + } + + // Top Tracks (by country using YouTube Music search) + if (pathname === "/api/top/tracks") { + const country = searchParams.get("country") || undefined; + const limit = parseInt(searchParams.get("limit") || "20"); + const result = await getTopTracks(country, limit, ytmusic); + return json(result); + } + + // Artist Info (detailed from Last.fm) + if (pathname === "/api/artist/info") { + const artist = searchParams.get("artist"); + if (!artist) { + return error("Missing required parameter: artist"); + } + const result = await getArtistInfo(artist); + return json(result); + } + + // Track Info (detailed from Last.fm) + if (pathname === "/api/track/info") { + const title = searchParams.get("title"); + const artist = searchParams.get("artist"); + if (!title || !artist) { + return error("Missing required parameters: title and artist"); + } + const result = await getTrackInfo(title, artist); + return json(result); + } + + // 404 + return json({ error: "Route not found", path: pathname }, 404); + + } catch (err) { + console.error("Error:", err); + return json({ error: "Internal server error", message: String(err) }, 500); + } +} + +// Audio proxy endpoint to bypass CORS +async function proxyAudio(url: string): Promise { + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + + if (!response.ok) { + return new Response("Failed to fetch audio", { status: 502 }); + } + + const headers = new Headers(); + headers.set("Content-Type", response.headers.get("Content-Type") || "audio/mp4"); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("Cache-Control", "public, max-age=3600"); + + const contentLength = response.headers.get("Content-Length"); + if (contentLength) { + headers.set("Content-Length", contentLength); + } + + return new Response(response.body, { headers }); + } catch (err) { + return new Response("Proxy error: " + String(err), { status: 502 }); + } +} + +// Fetch channel videos using YouTube Browse API +async function fetchChannelVideos(channelId: string, limit?: number): Promise { + try { + const url = "https://www.youtube.com/youtubei/v1/browse?prettyPrint=false"; + const payload = { + browseId: channelId, + context: { + client: { + clientName: "WEB", + clientVersion: "2.20251013.01.00", + hl: "en", + gl: "US", + }, + }, + }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + const items: any[] = []; + + // Extract channel name + let channelName = data?.header?.c4TabbedHeaderRenderer?.title || + data?.metadata?.channelMetadataRenderer?.title || ""; + + // Extract videos from response + const extractVideos = (contents: any[]) => { + if (!contents) return; + for (const item of contents) { + const video = item?.richItemRenderer?.content?.videoRenderer || + item?.videoRenderer || + item?.gridVideoRenderer; + if (video?.videoId) { + items.push(parseVideo(video, channelId, channelName)); + } + // Handle nested content + if (item?.shelfRenderer?.content?.expandedShelfContentsRenderer?.items) { + extractVideos(item.shelfRenderer.content.expandedShelfContentsRenderer.items); + } + if (item?.itemSectionRenderer?.contents) { + extractVideos(item.itemSectionRenderer.contents); + } + if (limit && items.length >= limit) return; + } + }; + + // Try different response structures + const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || + data?.contents?.singleColumnBrowseResultsRenderer?.tabs || []; + + for (const tab of tabs) { + const contents = tab?.tabRenderer?.content?.sectionListRenderer?.contents || + tab?.tabRenderer?.content?.richGridRenderer?.contents || []; + extractVideos(contents); + } + + return items.slice(0, limit || items.length); + } catch (err) { + console.error("Channel fetch error:", err); + return []; + } +} + +function parseVideo(video: any, channelId: string, channelName: string): any { + const id = video?.videoId || ""; + const title = video?.title?.runs?.[0]?.text || video?.title?.simpleText || ""; + + // Parse duration + let duration = 0; + const durationText = video?.lengthText?.simpleText || + video?.thumbnailOverlays?.[0]?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || ""; + if (durationText) { + const parts = durationText.split(":").map((p: string) => parseInt(p) || 0); + if (parts.length === 2) duration = parts[0] * 60 + parts[1]; + else if (parts.length === 3) duration = parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + + // Parse views + let views = 0; + const viewText = video?.viewCountText?.simpleText || ""; + const match = viewText.match(/([\d,\.]+)([KMB]?)/); + if (match) { + let num = parseFloat(match[1].replace(/,/g, "")); + if (match[2] === "K") num *= 1000; + else if (match[2] === "M") num *= 1000000; + else if (match[2] === "B") num *= 1000000000; + views = Math.floor(num); + } + + // Parse published time + let uploaded = Date.now(); + const timeText = (video?.publishedTimeText?.simpleText || "").toLowerCase(); + if (timeText.includes("hour")) uploaded -= parseInt(timeText.match(/(\d+)/)?.[1] || "1") * 3600000; + else if (timeText.includes("day")) uploaded -= parseInt(timeText.match(/(\d+)/)?.[1] || "1") * 86400000; + else if (timeText.includes("week")) uploaded -= parseInt(timeText.match(/(\d+)/)?.[1] || "1") * 604800000; + else if (timeText.includes("month")) uploaded -= parseInt(timeText.match(/(\d+)/)?.[1] || "1") * 2592000000; + else if (timeText.includes("year")) uploaded -= parseInt(timeText.match(/(\d+)/)?.[1] || "1") * 31536000000; + + const isShort = duration > 0 && duration <= 60; + + return { + id, + authorId: channelId, + duration: duration.toString(), + author: channelName, + views: views.toString(), + uploaded: uploaded.toString(), + title, + isShort, + thumbnail: video?.thumbnail?.thumbnails?.slice(-1)[0]?.url || "", + }; +} + +// Start server +console.log(`Virome API running on http://localhost:${PORT}`); +console.log(`Endpoints: /api/search, /api/stream, /api/charts, etc.`); + +serve(handler, { port: PORT }); diff --git a/ui.ts b/ui.ts new file mode 100644 index 0000000..96d5627 --- /dev/null +++ b/ui.ts @@ -0,0 +1,266 @@ +/** + * Virome API - UI HTML Template + */ + +export const html = ` + + + + + Virome API + + + + +
+
+ + Virome APIUNOFFICIAL +
+
+
+ Get Started +

Explore the Docs

+

Check out the documentation to learn how to use the Virome API for music search and streaming.

+
+
+ Open Source +

Open Source

+

Virome API is open-source. Built with Deno for fast, secure music data access.

+
+
+ Features +

What You Can Do

+

Search songs, albums, artists. Get streaming URLs, related tracks, and more.

+
+
+ Try It +

Test the Player

+

Use the built-in player to search and play music directly from the API.

+
+
+
+ + + +
+
+
Search Endpoints
+
+
GET/api/search
Search YouTube Music. Params: q, filter (songs/albums/artists)
+
GET/api/yt_search
Search YouTube. Params: q, filter (videos/channels/playlists)
+
GET/api/search/suggestions
Get search suggestions. Params: q
+
+
Content Endpoints
+
+
GET/api/songs/:videoId
Get song details by video ID
+
GET/api/albums/:browseId
Get album details and tracks
+
GET/api/artists/:browseId
Get artist info and top songs
+
GET/api/playlists/:playlistId
Get playlist details and tracks
+
+
Discovery Endpoints
+
+
GET/api/related/:videoId
Get related songs for a video
+
GET/api/similar
Find similar tracks. Params: title, artist
+
GET/api/trending
Most played songs in a country
+
GET/api/radio
Generate radio mix. Params: videoId
+
GET/api/top/artists
Most listened artists in a country
+
GET/api/top/tracks
Most listened tracks in a country
+
+
Info Endpoints
+
+
GET/api/lyrics
Get lyrics. Params: title, artist
+
GET/api/artist/info
Artist bio and stats. Params: artist
+
GET/api/track/info
Track details. Params: title, artist
+
+
Streaming Endpoints
+
+
GET/api/stream
Get stream URLs. Params: id
+
GET/api/watch_playlist
Get watch playlist. Params: videoId or playlistId
+
GET/health
Health check endpoint
+
+
+ +
+ + +
Searching...
+
+
+
+
+ +
+
+
GET /api/search?q=Drake
+ +
Click "Test Endpoint" to see the response...
+
+
Virome API
+
+
+
+
+ +
+
-
+
-
+
+
+ + + +
+
+
+ 0:00 +
+
+
+ 0:00 +
+
+
+
+ + +`;