odesli.js
Version:
Node.js Client to query odesli.co (song.link/album.link) API
1,189 lines (1,082 loc) • 42.9 kB
JavaScript
const fetch = require('node-fetch')
const { MetricsCollector } = require('./metrics')
// Read package version for User-Agent
const packageJson = require('../package.json')
const PACKAGE_VERSION = packageJson.version
const PACKAGE_NAME = packageJson.name
// Get Node.js version and platform for User-Agent
const NODE_VERSION = process.version
const PLATFORM = process.platform
/**
* Generate User-Agent string
* @returns {string} User-Agent string
* @private
*/
function generateUserAgent () {
return `${PACKAGE_NAME}/${PACKAGE_VERSION} (Node.js ${NODE_VERSION}; ${PLATFORM})`
}
// Global cache storage
const cache = new Map()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
function clearExpiredCache () {
const now = Date.now()
for (const [key, value] of cache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}
/**
* Get cached response if available and not expired
* @param {string} key - Cache key
* @param {MetricsCollector} metrics - Metrics collector instance
* @returns {Object|null} Cached response or null if not found/expired
* @private
*/
function getCachedResponse (key, metrics) {
clearExpiredCache()
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
if (metrics) metrics.recordRequest({ cacheHit: true })
return cached.data
}
if (metrics) metrics.recordRequest({ cacheHit: false })
return null
}
/**
* Cache a response
* @param {string} key - Cache key
* @param {Object} data - Response data to cache
* @private
*/
function cacheResponse (key, data) {
cache.set(key, {
data,
timestamp: Date.now()
})
}
/**
* Odesli API Client
*
* A Node.js client for the Odesli API (formerly song.link/album.link) that helps you find links to music across multiple streaming platforms.
*
* @example
* ```javascript
* const Odesli = require('odesli.js');
*
* // Initialize without API key (10 requests/minute limit)
* const odesli = new Odesli();
*
* // Or with API key for higher limits
* const odesli = new Odesli({
* apiKey: 'your-api-key-here',
* version: 'v1-alpha.1'
* });
*
* // Fetch a song by URL
* const song = await odesli.fetch('https://open.spotify.com/track/4Km5HrUvYTaSUfiSGPJeQR');
* console.log(`${song.title} by ${song.artist[0]}`);
* ```
*/
module.exports = class Odesli {
/**
* Create a new Odesli instance
*
* @param {Object} options - Configuration options
* @param {string} [options.apiKey] - Optional API key for higher rate limits (10+ requests/minute). Without an API key, you're limited to 10 requests per minute.
* @param {string} [options.version='v1-alpha.1'] - API version to use
* @param {boolean} [options.cache=true] - Enable response caching (5 minute TTL)
* @param {number} [options.timeout=10000] - Request timeout in milliseconds
* @param {number} [options.maxRetries=3] - Maximum number of retry attempts for failed requests
* @param {number} [options.retryDelay=1000] - Base delay between retries in milliseconds (uses exponential backoff)
* @param {Object} [options.headers={}] - Additional headers to include in requests
* @param {string} [options.baseUrl='https://api.song.link'] - Base URL for API requests
* @param {boolean} [options.validateParams=true] - Enable parameter validation
* @param {Function} [options.logger] - Optional logger function for debugging
* @param {MetricsCollector|boolean} [options.metrics] - Optional metrics collector instance or `false` to disable
*
* @example
* ```javascript
* // Basic usage without API key
* const odesli = new Odesli();
*
* // With API key for higher limits
* const odesli = new Odesli({
* apiKey: 'your-api-key-here',
* version: 'v1-alpha.1',
* cache: true,
* timeout: 10000,
* maxRetries: 3,
* retryDelay: 1000,
* headers: { 'User-Agent': 'MyApp/1.0' },
* validateParams: true
* });
*
* // Disable metrics
* const odesli = new Odesli({ metrics: false });
* ```
*/
constructor (options = {}) {
const {
apiKey = undefined,
version = 'v1-alpha.1',
cache = true,
timeout = 10000,
maxRetries = 3,
retryDelay = 1000,
headers = {},
baseUrl = 'https://api.song.link',
validateParams = true,
logger = null,
metrics
} = options || {}
this.apiKey = apiKey || undefined
this.version = version || 'v1-alpha.1'
this.cacheEnabled = cache
this.timeout = timeout
this.maxRetries = maxRetries
this.retryDelay = retryDelay
this.customHeaders = headers
this.baseUrl = baseUrl
this.validateParams = validateParams
this.logger = logger
if (metrics === false) {
this.metrics = new MetricsCollector({ enabled: false })
} else {
this.metrics = metrics || new MetricsCollector()
}
}
/**
* Validate URL format
* @param {string} url - URL to validate
* @returns {boolean} True if valid URL
* @private
*/
_validateUrl (url) {
if (!this.validateParams) return true
try {
const urlObj = new URL(url)
return urlObj.href.length > 0
} catch {
return false
}
}
/**
* Validate country code format
* @param {string} country - Country code to validate
* @returns {boolean} True if valid ISO 3166-1 Alpha-2 code
* @private
*/
_validateCountry (country) {
if (!this.validateParams) return true
return /^[A-Z]{2}$/.test(country)
}
/**
* Validate platform name
* @param {string} platform - Platform to validate
* @returns {boolean} True if valid platform
* @private
*/
_validatePlatform (platform) {
if (!this.validateParams) return true
const validPlatforms = [
'spotify', 'itunes', 'appleMusic', 'youtube', 'youtubeMusic',
'google', 'googleStore', 'pandora', 'deezer', 'tidal',
'amazonStore', 'amazonMusic', 'soundcloud', 'napster', 'yandex', 'spinrilla'
]
return validPlatforms.includes(platform.toLowerCase())
}
/**
* Validate entity type
* @param {string} type - Type to validate
* @returns {boolean} True if valid type
* @private
*/
_validateType (type) {
if (!this.validateParams) return true
return ['song', 'album'].includes(type.toLowerCase())
}
/**
* Log message if logger is configured
* @param {string} message - Message to log
* @param {string} level - Log level (debug, info, warn, error)
* @private
*/
_log (message, level = 'info') {
if (this.logger && typeof this.logger === 'function') {
this.logger(message, level)
}
}
/**
* Make a request to the Odesli API with caching, timeout, and retry support
*
* @param {string} path - API endpoint path
* @returns {Promise<Object>} API response
* @private
*/
async _request (path) {
const url = `${this.baseUrl}/${this.version}/${path}${this.apiKey !== undefined ? `&key=${this.apiKey}` : ''}`
// Check cache first if enabled
if (this.cacheEnabled) {
const cached = getCachedResponse(url, this.metrics)
if (cached) {
this._log(`Cache hit for ${path}`, 'debug')
return cached
}
}
let lastError
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
this._log(`Request attempt ${attempt}/${this.maxRetries} for ${path}`, 'debug')
// Create AbortController for timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': generateUserAgent(),
...this.customHeaders,
Accept: 'application/json'
}
})
clearTimeout(timeoutId)
if (!response || !response.ok) {
if (!response) {
if (lastError) throw lastError
throw new Error('API returned an unexpected result.')
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
// Handle errors
if (result.statusCode) {
// Codes in the `4xx` range indicate an error that failed given the information provided
if (result.statusCode === 429) throw new Error(`${result.statusCode}: ${result.code}, You are being rate limited, No API Key is 10 Requests / Minute.`)
// Codes in the `4xx` range indicate an error that failed given the information provided
if (result.statusCode.toString().startsWith(4)) throw new Error(`${result.statusCode}: ${result.code}, Codes in the 4xx range indicate an error that failed given the information provided.`)
// Codes in the 5xx range indicate an error with Songlink's servers.
if (result.statusCode.toString().startsWith(5)) throw new Error(`${result.statusCode}: ${result.code}, Codes in the 5xx range indicate an error with Songlink's servers.`)
// Otherwise if the code is not 200 (Success), throw a generic error.
if (result.statusCode !== 200) throw new Error(`${result.statusCode}: ${result.code}`)
// return undefined as we didn't find anything.
return undefined
}
// Cache successful responses
if (this.cacheEnabled && result) {
cacheResponse(url, result)
}
this._log(`Request successful for ${path}`, 'debug')
return result
} catch (err) {
lastError = err
// Don't retry on certain errors
if (err.message.includes('429') || err.message.includes('4xx') || err.message.includes('5xx')) {
this._log(`Non-retryable error: ${err.message}`, 'error')
break
}
if (attempt < this.maxRetries) {
const delay = this.retryDelay * Math.pow(2, attempt - 1) // Exponential backoff
this._log(`Request failed, retrying in ${delay}ms: ${err.message}`, 'warn')
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// If we get here, all retries failed
if (lastError.name === 'AbortError') {
throw new Error(`Request timeout after ${this.timeout}ms`)
}
if (lastError.message === 'Unexpected token < in JSON at position 0') {
throw new Error('API returned an unexpected result.')
}
// Preserve original error message for network errors
throw lastError
}
/**
* Clear the response cache
*
* @example
* ```javascript
* const odesli = new Odesli();
* odesli.clearCache(); // Clear all cached responses
* ```
*/
clearCache () {
cache.clear()
this.metrics.reset() // Also reset metrics related to cache
this._log('Cache cleared', 'info')
}
/**
* Get cache statistics
*
* @returns {Object} Cache statistics
*/
getCacheStats () {
const summary = this.metrics.getSummary()
return {
hitCount: summary.counters.cacheHits,
missCount: summary.counters.cacheMisses,
hitRate: summary.rates.cacheHitRate,
size: cache.size,
}
}
/**
* Get the metrics collector instance for more detailed statistics.
*
* @returns {MetricsCollector} The metrics collector instance.
*/
getMetrics () {
return this.metrics
}
/**
* Fetch song/album information by URL(s) from any supported streaming platform
*
* @param {string|Array<string>} urlOrUrls - URL or array of URLs from any supported streaming platform (Spotify, Apple Music, YouTube, etc.)
* @param {Object|string} [options] - Options object or country code string (for backward compatibility)
* @param {string} [options.country='US'] - ISO 3166-1 Alpha-2 country code for region-specific results
* @param {boolean} [options.skipCache=false] - Skip cache for this request
* @param {number} [options.timeout] - Override timeout for this request
* @param {number} [options.concurrency=5] - Maximum concurrent requests (batch only)
* @returns {Promise<Object>|Promise<Array<Object>>} Song/album information or array of results
*
* @example
* // Single fetch
* const song = await odesli.fetch('https://open.spotify.com/track/4Km5HrUvYTaSUfiSGPJeQR');
*
* // Batch fetch
* const songs = await odesli.fetch([
* 'https://open.spotify.com/track/4Km5HrUvYTaSUfiSGPJeQR',
* 'https://open.spotify.com/track/0V3wPSX9ygBnCm8psDIegu'
* ]);
*/
async fetch (urlOrUrls, options = {}) {
if (Array.isArray(urlOrUrls)) {
// Batch mode
const urls = urlOrUrls
if (urls.length === 0) {
return []
}
const { concurrency = 5, ...requestOptions } = options
const results = []
const chunks = []
// Split URLs into chunks for concurrency control
for (let i = 0; i < urls.length; i += concurrency) {
chunks.push(urls.slice(i, i + concurrency))
}
for (const chunk of chunks) {
const chunkPromises = chunk.map(async (url, _index) => {
try {
const result = await this.fetch(url, requestOptions)
// Add success property to successful responses
return { ...result, success: true }
} catch (error) {
// Enhanced error object with more context
const errorInfo = {
success: false,
url,
error: error.message,
platform: this.detectPlatform(url),
extractedId: this.extractId(url),
timestamp: new Date().toISOString(),
statusCode: this._extractStatusCode(error.message),
retryable: this._isRetryableError(error.message)
}
// Add additional context based on error type
if (error.message.includes('400')) {
errorInfo.errorType = 'BAD_REQUEST'
errorInfo.suggestion = 'Check if the URL is valid and accessible'
} else if (error.message.includes('401')) {
errorInfo.errorType = 'UNAUTHORIZED'
errorInfo.suggestion = 'API key may be required or invalid'
} else if (error.message.includes('429')) {
errorInfo.errorType = 'RATE_LIMITED'
errorInfo.suggestion = 'Rate limit exceeded, try again later'
} else if (error.message.includes('404')) {
errorInfo.errorType = 'NOT_FOUND'
errorInfo.suggestion = 'Content may not be available in the specified region'
} else if (error.message.includes('timeout')) {
errorInfo.errorType = 'TIMEOUT'
errorInfo.suggestion = 'Request timed out, try again'
} else {
errorInfo.errorType = 'UNKNOWN'
errorInfo.suggestion = 'An unexpected error occurred'
}
return errorInfo
}
})
const chunkResults = await Promise.all(chunkPromises)
results.push(...chunkResults)
}
return results
}
const url = urlOrUrls
if (!url) throw new Error('No URL was provided to odesli.fetch()')
// Handle backward compatibility: if second parameter is a string, treat it as country
let country = 'US'
let skipCache = false
let timeout = this.timeout
if (typeof options === 'string') {
country = options
} else {
country = options.country || 'US'
skipCache = options.skipCache || false
timeout = options.timeout || this.timeout
}
// Validate parameters
if (!this._validateUrl(url)) {
throw new Error('Invalid URL format provided to odesli.fetch()')
}
if (!this._validateCountry(country)) {
throw new Error('Invalid country code format. Must be ISO 3166-1 Alpha-2 (e.g., "US", "GB")')
}
const path = `links?url=${encodeURIComponent(url)}&userCountry=${country}`
// Temporarily override timeout and cache settings if specified
const originalTimeout = this.timeout
const originalCacheEnabled = this.cacheEnabled
if (timeout !== this.timeout) {
this.timeout = timeout
}
if (skipCache) {
this.cacheEnabled = false
}
try {
const song = await this._request(path)
// Handle edge cases where response is malformed
if (!song || !song.entityUniqueId || !song.entitiesByUniqueId || !song.entitiesByUniqueId[song.entityUniqueId]) {
return song || {}
}
const id = song.entitiesByUniqueId[song.entityUniqueId].id
const title = song.entitiesByUniqueId[song.entityUniqueId].title
// Convert Artist into Array for easier extraction of features
Object.values(song.entitiesByUniqueId).forEach(function (values) {
if (values.artistName && typeof values.artistName === 'string') {
values.artistName = values.artistName.split(', ')
}
})
const artist = song.entitiesByUniqueId[song.entityUniqueId].artistName
const type = song.entitiesByUniqueId[song.entityUniqueId].type
const thumbnail = song.entitiesByUniqueId[song.entityUniqueId].thumbnailUrl
return {
...song,
id,
title,
artist,
type,
thumbnail
}
} finally {
// Restore original settings
this.timeout = originalTimeout
this.cacheEnabled = originalCacheEnabled
}
}
/**
* Fetch song/album information by platform, type, and ID
*
* @param {string} platform - Platform name (spotify, appleMusic, youtube, etc.)
* @param {string} type - Content type ('song' or 'album')
* @param {string} id - Platform-specific ID (can be full format like 'SPOTIFY_SONG::123' or just '123')
* @param {Object|string} [options] - Options object or country code string (for backward compatibility)
* @param {string} [options.country='US'] - ISO 3166-1 Alpha-2 country code for region-specific results
* @param {boolean} [options.skipCache=false] - Skip cache for this request
* @param {number} [options.timeout] - Override timeout for this request
* @returns {Promise<Object>} Song/album information with links to all available platforms
*
* @example
* ```javascript
* // Get song by Spotify ID
* const song = await odesli.getByParams('spotify', 'song', '4Km5HrUvYTaSUfiSGPJeQR');
* console.log(song.title);
*
* // Get album by Apple Music ID (backward compatible)
* const album = await odesli.getByParams('appleMusic', 'album', '123456789', 'GB');
*
* // Using full ID format with options
* const song = await odesli.getByParams('spotify', 'song', 'SPOTIFY_SONG::4Km5HrUvYTaSUfiSGPJeQR', {
* country: 'GB',
* skipCache: true,
* timeout: 5000
* });
* ```
*
* @throws {Error} When platform, type, or ID is not provided
* @throws {Error} When platform name is invalid
* @throws {Error} When type is invalid
* @throws {Error} When country code format is invalid
* @throws {Error} When API returns an error
*/
async getByParams (platform, type, id, options = {}) {
if (!platform) throw new Error('No `platform` was provided to odesli.getByParams()')
if (!type) throw new Error('No `type` was provided to odesli.getByParams()')
if (!id) throw new Error('No `id` was provided to odesli.getByParams()')
// Handle backward compatibility: if fourth parameter is a string, treat it as country
let country = 'US'
let skipCache = false
let timeout = this.timeout
if (typeof options === 'string') {
country = options
} else {
country = options.country || 'US'
skipCache = options.skipCache || false
timeout = options.timeout || this.timeout
}
// Validate parameters
if (!this._validatePlatform(platform)) {
throw new Error(`Invalid platform "${platform}". Must be one of: spotify, itunes, appleMusic, youtube, youtubeMusic, google, googleStore, pandora, deezer, tidal, amazonStore, amazonMusic, soundcloud, napster, yandex, spinrilla`)
}
if (!this._validateType(type)) {
throw new Error(`Invalid type "${type}". Must be "song" or "album"`)
}
if (!this._validateCountry(country)) {
throw new Error('Invalid country code format. Must be ISO 3166-1 Alpha-2 (e.g., "US", "GB")')
}
// if they happen to input the full id (PLATFORM_SONG::UNIQUEID), just get the UNIQUEID
const idParts = id.split('::')
const path = `links?platform=${platform}&type=${type}&id=${idParts.length > 1 ? idParts[1] : id}&userCountry=${country}`
// Temporarily override timeout and cache settings if specified
const originalTimeout = this.timeout
const originalCacheEnabled = this.cacheEnabled
if (timeout !== this.timeout) {
this.timeout = timeout
}
if (skipCache) {
this.cacheEnabled = false
}
try {
const song = await this._request(path)
// Handle edge cases where response is malformed
if (!song || !song.entityUniqueId || !song.entitiesByUniqueId || !song.entitiesByUniqueId[song.entityUniqueId]) {
return song || {}
}
// Convert Artist into Array for easier extraction of features
Object.values(song.entitiesByUniqueId).forEach(function (values) {
if (values.artistName && typeof values.artistName === 'string') {
values.artistName = values.artistName.split(', ')
}
})
// Easier extraction of page's title, album, and thumbnail
const title = song.entitiesByUniqueId[song.entityUniqueId].title
const artist = song.entitiesByUniqueId[song.entityUniqueId].artistName
const thumbnail = song.entitiesByUniqueId[song.entityUniqueId].thumbnailUrl
const entityType = song.entitiesByUniqueId[song.entityUniqueId].type
return {
...song,
title,
artist,
thumbnail,
type: entityType
}
} finally {
// Restore original settings
this.timeout = originalTimeout
this.cacheEnabled = originalCacheEnabled
}
}
/**
* Fetch song/album information by entity ID
*
* @param {string} id - Full entity ID in format 'PLATFORM_TYPE::UNIQUEID' (e.g., 'SPOTIFY_SONG::4Km5HrUvYTaSUfiSGPJeQR')
* @param {Object|string} [options] - Options object or country code string (for backward compatibility)
* @param {string} [options.country='US'] - ISO 3166-1 Alpha-2 country code for region-specific results
* @param {boolean} [options.skipCache=false] - Skip cache for this request
* @param {number} [options.timeout] - Override timeout for this request
* @returns {Promise<Object>} Song/album information with links to all available platforms
*
* @example
* ```javascript
* // Get song by entity ID
* const song = await odesli.getById('SPOTIFY_SONG::4Km5HrUvYTaSUfiSGPJeQR');
* console.log(song.title);
*
* // Get album by entity ID (backward compatible)
* const album = await odesli.getById('APPLEMUSIC_ALBUM::123456789', 'GB');
*
* // Get song by entity ID with options
* const song = await odesli.getById('SPOTIFY_SONG::4Km5HrUvYTaSUfiSGPJeQR', {
* country: 'GB',
* skipCache: true,
* timeout: 5000
* });
* ```
*
* @throws {Error} When ID is not provided
* @throws {Error} When ID format is invalid (must match 'PLATFORM_TYPE::UNIQUEID')
* @throws {Error} When country code format is invalid
* @throws {Error} When API returns an error
*/
async getById(id, options = {}) {
if (!id) throw new Error('No `id` was provided to odesli.getById()')
const idParts = id.split('::');
if (idParts.length !== 2 || !idParts[0] || !idParts[1] || !idParts[0].includes('_')) {
throw new Error('Provided Entity ID Does not match format. `<PLATFORM>_<SONG|ALBUM>::<UNIQUEID>`')
}
// Handle backward compatibility: if second parameter is a string, treat it as country
let country = 'US'
let skipCache = false
let timeout = this.timeout
if (typeof options === 'string') {
country = options
} else {
country = options.country || 'US'
skipCache = options.skipCache || false
timeout = options.timeout || this.timeout
}
// Validate country code
if (!this._validateCountry(country)) {
throw new Error('Invalid country code format. Must be ISO 3166-1 Alpha-2 (e.g., "US", "GB")')
}
// Convert string into seperate params
const parts = id.split('::')
const unique = parts[1] || ''
const platformAndType = (parts[0] || '').split('_')
const type = platformAndType.pop() || ''
const platform = platformAndType.join('_')
const path = `links?platform=${platform.toLowerCase()}&type=${type.toLowerCase()}&id=${unique}&userCountry=${country}`
// Temporarily override timeout and cache settings if specified
const originalTimeout = this.timeout
const originalCacheEnabled = this.cacheEnabled
if (timeout !== this.timeout) {
this.timeout = timeout
}
if (skipCache) {
this.cacheEnabled = false
}
try {
const song = await this._request(path)
// Handle edge cases where response is malformed
if (!song || !song.entityUniqueId || !song.entitiesByUniqueId || !song.entitiesByUniqueId[song.entityUniqueId]) {
return song || {}
}
// Convert Artist into Array for easier extraction of features
Object.values(song.entitiesByUniqueId).forEach(function (values) {
if (values.artistName && typeof values.artistName === 'string') {
values.artistName = values.artistName.split(', ')
}
})
// Easier extraction of page's title, album, and thumbnail
const title = song.entitiesByUniqueId[song.entityUniqueId].title
const artist = song.entitiesByUniqueId[song.entityUniqueId].artistName
const thumbnail = song.entitiesByUniqueId[song.entityUniqueId].thumbnailUrl
const entityType = song.entitiesByUniqueId[song.entityUniqueId].type
return {
...song,
title,
artist,
thumbnail,
type: entityType
}
} finally {
// Restore original settings
this.timeout = originalTimeout
this.cacheEnabled = originalCacheEnabled
}
}
/**
* Extract status code from error message
* @private
*/
_extractStatusCode(errorMessage) {
const match = errorMessage.match(/(\\d{3}):/)
return match ? parseInt(match[1]) : null
}
/**
* Determine if an error is retryable
* @private
*/
_isRetryableError(errorMessage) {
const retryableStatusCodes = [408, 429, 500, 502, 503, 504]
const statusCode = this._extractStatusCode(errorMessage)
return statusCode ? retryableStatusCodes.includes(statusCode) : false
}
/**
* Detect platform from URL
*
* @param {string} url - URL to analyze
* @returns {string|null} Platform name or null if not recognized
*
* @example
* ```javascript
* const platform = odesli.detectPlatform('https://open.spotify.com/track/123');
* console.log(platform); // 'spotify'
* ```
*/
detectPlatform (url) {
if (!url) return null
try {
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
const pathname = urlObj.pathname.toLowerCase()
if (['music.youtube.com'].includes(hostname) || (['youtube.com', 'www.youtube.com'].includes(hostname) && pathname.startsWith('/music'))) return 'youtubeMusic'
if (['youtube.com', 'www.youtube.com', 'youtu.be'].includes(hostname)) return 'youtube'
if (['music.amazon.com'].includes(hostname) || (['amazon.com', 'www.amazon.com'].includes(hostname) && pathname.startsWith('/music'))) return 'amazonMusic'
if (['music.yandex.ru'].includes(hostname) || (['yandex.ru'].includes(hostname) && pathname.startsWith('/music'))) return 'yandex'
if (['open.spotify.com', 'spotify.com'].includes(hostname)) return 'spotify'
if (['music.apple.com', 'itunes.apple.com'].includes(hostname)) return 'appleMusic'
if (['tidal.com', 'listen.tidal.com'].includes(hostname)) return 'tidal'
if (['deezer.com', 'www.deezer.com'].includes(hostname)) return 'deezer'
if (['soundcloud.com', 'www.soundcloud.com'].includes(hostname)) return 'soundcloud'
if (['pandora.com', 'www.pandora.com'].includes(hostname)) return 'pandora'
if (['napster.com', 'us.napster.com'].includes(hostname)) return 'napster'
if (['spinrilla.com', 'www.spinrilla.com'].includes(hostname)) return 'spinrilla'
} catch {
// Malformed URL, just ignore.
}
return null
}
/**
* Extract platform-specific ID from a URL
*
* @param {string} url - URL to extract ID from
* @returns {string|null} Platform-specific ID or null if not found
*
* @example
* ```javascript
* const id = odesli.extractId('https://open.spotify.com/track/4Km5HrUvYTaSUfiSGPJeQR');
* console.log(id); // '4Km5HrUvYTaSUfiSGPJeQR'
* ```
*/
extractId (url) {
try {
const urlObj = new URL(url)
const pathParts = urlObj.pathname.split('/').filter(Boolean)
const hostname = urlObj.hostname.toLowerCase()
// Spotify
if (hostname === 'open.spotify.com') {
return pathParts[pathParts.length - 1]
}
// Apple Music - improved to handle various URL formats
if (['music.apple.com', 'itunes.apple.com'].includes(hostname)) {
// Handle album URLs with track IDs: /album/name/id?i=trackId
const trackIdMatch = url.match(/[?&]i=(\d+)/)
if (trackIdMatch) {
return trackIdMatch[1]
}
// Handle direct track URLs: /album/name/id
const idMatch = url.match(/\/id(\d+)/)
if (idMatch) {
return idMatch[1]
}
// Handle other Apple Music URL formats
const lastPart = pathParts[pathParts.length - 1];
if (/^\d+$/.test(lastPart)) {
return lastPart;
}
return null
}
// YouTube
if (['youtube.com', 'www.youtube.com', 'music.youtube.com', 'youtu.be'].includes(hostname)) {
const videoMatch = url.match(/(?:v=|\/)([a-zA-Z0-9_-]{11})/)
return videoMatch ? videoMatch[1] : null
}
// Tidal
if (['tidal.com', 'listen.tidal.com'].includes(hostname)) {
return pathParts[pathParts.length - 1]
}
// Deezer
if (['deezer.com', 'www.deezer.com'].includes(hostname)) {
return pathParts[pathParts.length - 1]
}
// Amazon Music
if (['music.amazon.com'].includes(hostname) || (['amazon.com', 'www.amazon.com'].includes(hostname) && urlObj.pathname.toLowerCase().startsWith('/music'))) {
const idMatch = url.match(/\/albums\/([^/]+)/)
return idMatch ? idMatch[1] : null
}
// SoundCloud
if (['soundcloud.com', 'www.soundcloud.com'].includes(hostname)) {
return pathParts[pathParts.length - 1]
}
return null
} catch {
return null
}
}
/**
* Get supported platforms list
*
* @returns {Array<string>} Array of supported platform names
*
* @example
* ```javascript
* const platforms = odesli.getSupportedPlatforms();
* console.log(platforms); // ['spotify', 'appleMusic', 'youtube', ...]
* ```
*/
getSupportedPlatforms () {
return [
'spotify', 'itunes', 'appleMusic', 'youtube', 'youtubeMusic',
'google', 'googleStore', 'pandora', 'deezer', 'tidal',
'amazonStore', 'amazonMusic', 'soundcloud', 'napster', 'yandex', 'spinrilla'
]
}
/**
* Get the current User-Agent string used for requests
*
* @returns {string} Current User-Agent string
* @example
* ```javascript
* const odesli = new Odesli();
* console.log(odesli.getUserAgent());
* // Output: "@mattraus/odesli.js/1.1.2 (Node.js v18.17.0; win32)"
* ```
*/
getUserAgent () {
return generateUserAgent()
}
/**
* Get all valid ISO 3166-1 alpha-2 country codes and names
* @returns {Array<{ code: string, name: string }>} Array of country code/name pairs
*/
static getCountryOptions() {
// List from the CountryCode enum
return [
{ code: 'AF', name: 'Afghanistan' },
{ code: 'AX', name: 'Aland Islands' },
{ code: 'AL', name: 'Albania' },
{ code: 'DZ', name: 'Algeria' },
{ code: 'AS', name: 'American Samoa' },
{ code: 'AD', name: 'Andorra' },
{ code: 'AO', name: 'Angola' },
{ code: 'AI', name: 'Anguilla' },
{ code: 'AQ', name: 'Antarctica' },
{ code: 'AG', name: 'Antigua and Barbuda' },
{ code: 'AR', name: 'Argentina' },
{ code: 'AM', name: 'Armenia' },
{ code: 'AW', name: 'Aruba' },
{ code: 'AU', name: 'Australia' },
{ code: 'AT', name: 'Austria' },
{ code: 'AZ', name: 'Azerbaijan' },
{ code: 'BS', name: 'Bahamas' },
{ code: 'BH', name: 'Bahrain' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BB', name: 'Barbados' },
{ code: 'BY', name: 'Belarus' },
{ code: 'BE', name: 'Belgium' },
{ code: 'BZ', name: 'Belize' },
{ code: 'BJ', name: 'Benin' },
{ code: 'BM', name: 'Bermuda' },
{ code: 'BT', name: 'Bhutan' },
{ code: 'BO', name: 'Bolivia' },
{ code: 'BQ', name: 'Bonaire, Sint Eustatius and Saba' },
{ code: 'BA', name: 'Bosnia and Herzegovina' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BV', name: 'Bouvet Island' },
{ code: 'BR', name: 'Brazil' },
{ code: 'IO', name: 'British Indian Ocean Territory' },
{ code: 'BN', name: 'Brunei Darussalam' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BI', name: 'Burundi' },
{ code: 'KH', name: 'Cambodia' },
{ code: 'CM', name: 'Cameroon' },
{ code: 'CA', name: 'Canada' },
{ code: 'CV', name: 'Cape Verde' },
{ code: 'KY', name: 'Cayman Islands' },
{ code: 'CF', name: 'Central African Republic' },
{ code: 'TD', name: 'Chad' },
{ code: 'CL', name: 'Chile' },
{ code: 'CN', name: 'China' },
{ code: 'CX', name: 'Christmas Island' },
{ code: 'CC', name: 'Cocos (Keeling) Islands' },
{ code: 'CO', name: 'Colombia' },
{ code: 'KM', name: 'Comoros' },
{ code: 'CG', name: 'Congo' },
{ code: 'CD', name: 'Congo, Democratic Republic' },
{ code: 'CK', name: 'Cook Islands' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'CI', name: "Cote d'Ivoire" },
{ code: 'HR', name: 'Croatia' },
{ code: 'CU', name: 'Cuba' },
{ code: 'CW', name: 'Curaçao' },
{ code: 'CY', name: 'Cyprus' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'DK', name: 'Denmark' },
{ code: 'DJ', name: 'Djibouti' },
{ code: 'DM', name: 'Dominica' },
{ code: 'DO', name: 'Dominican Republic' },
{ code: 'EC', name: 'Ecuador' },
{ code: 'EG', name: 'Egypt' },
{ code: 'SV', name: 'El Salvador' },
{ code: 'GQ', name: 'Equatorial Guinea' },
{ code: 'ER', name: 'Eritrea' },
{ code: 'EE', name: 'Estonia' },
{ code: 'ET', name: 'Ethiopia' },
{ code: 'FK', name: 'Falkland Islands' },
{ code: 'FO', name: 'Faroe Islands' },
{ code: 'FJ', name: 'Fiji' },
{ code: 'FI', name: 'Finland' },
{ code: 'FR', name: 'France' },
{ code: 'GF', name: 'French Guiana' },
{ code: 'PF', name: 'French Polynesia' },
{ code: 'TF', name: 'French Southern Territories' },
{ code: 'GA', name: 'Gabon' },
{ code: 'GM', name: 'Gambia' },
{ code: 'GE', name: 'Georgia' },
{ code: 'DE', name: 'Germany' },
{ code: 'GH', name: 'Ghana' },
{ code: 'GI', name: 'Gibraltar' },
{ code: 'GR', name: 'Greece' },
{ code: 'GL', name: 'Greenland' },
{ code: 'GD', name: 'Grenada' },
{ code: 'GP', name: 'Guadeloupe' },
{ code: 'GU', name: 'Guam' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'GG', name: 'Guernsey' },
{ code: 'GN', name: 'Guinea' },
{ code: 'GW', name: 'Guinea-Bissau' },
{ code: 'GY', name: 'Guyana' },
{ code: 'HT', name: 'Haiti' },
{ code: 'HM', name: 'Heard Island and McDonald Islands' },
{ code: 'VA', name: 'Holy See (Vatican City State)' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HK', name: 'Hong Kong' },
{ code: 'HU', name: 'Hungary' },
{ code: 'IS', name: 'Iceland' },
{ code: 'IN', name: 'India' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'IR', name: 'Iran' },
{ code: 'IQ', name: 'Iraq' },
{ code: 'IE', name: 'Ireland' },
{ code: 'IM', name: 'Isle of Man' },
{ code: 'IL', name: 'Israel' },
{ code: 'IT', name: 'Italy' },
{ code: 'JM', name: 'Jamaica' },
{ code: 'JP', name: 'Japan' },
{ code: 'JE', name: 'Jersey' },
{ code: 'JO', name: 'Jordan' },
{ code: 'KZ', name: 'Kazakhstan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KI', name: 'Kiribati' },
{ code: 'KR', name: 'Korea' },
{ code: 'KP', name: 'Korea, Democratic People\'s Republic' },
{ code: 'KW', name: 'Kuwait' },
{ code: 'KG', name: 'Kyrgyzstan' },
{ code: 'LA', name: 'Lao People\'s Democratic Republic' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LB', name: 'Lebanon' },
{ code: 'LS', name: 'Lesotho' },
{ code: 'LR', name: 'Liberia' },
{ code: 'LY', name: 'Libyan Arab Jamahiriya' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'MO', name: 'Macao' },
{ code: 'MK', name: 'Macedonia' },
{ code: 'MG', name: 'Madagascar' },
{ code: 'MW', name: 'Malawi' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'MV', name: 'Maldives' },
{ code: 'ML', name: 'Mali' },
{ code: 'MT', name: 'Malta' },
{ code: 'MH', name: 'Marshall Islands' },
{ code: 'MQ', name: 'Martinique' },
{ code: 'MR', name: 'Mauritania' },
{ code: 'MU', name: 'Mauritius' },
{ code: 'YT', name: 'Mayotte' },
{ code: 'MX', name: 'Mexico' },
{ code: 'FM', name: 'Micronesia' },
{ code: 'MD', name: 'Moldova' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MN', name: 'Mongolia' },
{ code: 'ME', name: 'Montenegro' },
{ code: 'MS', name: 'Montserrat' },
{ code: 'MA', name: 'Morocco' },
{ code: 'MZ', name: 'Mozambique' },
{ code: 'MM', name: 'Myanmar' },
{ code: 'NA', name: 'Namibia' },
{ code: 'NR', name: 'Nauru' },
{ code: 'NP', name: 'Nepal' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'NC', name: 'New Caledonia' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'NI', name: 'Nicaragua' },
{ code: 'NE', name: 'Niger' },
{ code: 'NG', name: 'Nigeria' },
{ code: 'NU', name: 'Niue' },
{ code: 'NF', name: 'Norfolk Island' },
{ code: 'MP', name: 'Northern Mariana Islands' },
{ code: 'NO', name: 'Norway' },
{ code: 'OM', name: 'Oman' },
{ code: 'PK', name: 'Pakistan' },
{ code: 'PW', name: 'Palau' },
{ code: 'PS', name: 'Palestinian Territory' },
{ code: 'PA', name: 'Panama' },
{ code: 'PG', name: 'Papua New Guinea' },
{ code: 'PY', name: 'Paraguay' },
{ code: 'PE', name: 'Peru' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PN', name: 'Pitcairn' },
{ code: 'PL', name: 'Poland' },
{ code: 'PT', name: 'Portugal' },
{ code: 'PR', name: 'Puerto Rico' },
{ code: 'QA', name: 'Qatar' },
{ code: 'RE', name: 'Reunion' },
{ code: 'RO', name: 'Romania' },
{ code: 'RU', name: 'Russian Federation' },
{ code: 'RW', name: 'Rwanda' },
{ code: 'BL', name: 'Saint Barthelemy' },
{ code: 'SH', name: 'Saint Helena' },
{ code: 'KN', name: 'Saint Kitts and Nevis' },
{ code: 'LC', name: 'Saint Lucia' },
{ code: 'MF', name: 'Saint Martin' },
{ code: 'PM', name: 'Saint Pierre and Miquelon' },
{ code: 'VC', name: 'Saint Vincent and Grenadines' },
{ code: 'WS', name: 'Samoa' },
{ code: 'SM', name: 'San Marino' },
{ code: 'ST', name: 'Sao Tome and Principe' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'SN', name: 'Senegal' },
{ code: 'RS', name: 'Serbia' },
{ code: 'SC', name: 'Seychelles' },
{ code: 'SL', name: 'Sierra Leone' },
{ code: 'SG', name: 'Singapore' },
{ code: 'SX', name: 'Sint Maarten' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'SB', name: 'Solomon Islands' },
{ code: 'SO', name: 'Somalia' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'GS', name: 'South Georgia and the South Sandwich Islands' },
{ code: 'SS', name: 'South Sudan' },
{ code: 'ES', name: 'Spain' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'SD', name: 'Sudan' },
{ code: 'SR', name: 'Suriname' },
{ code: 'SJ', name: 'Svalbard and Jan Mayen' },
{ code: 'SZ', name: 'Swaziland' },
{ code: 'SE', name: 'Sweden' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'SY', name: 'Syrian Arab Republic' },
{ code: 'TW', name: 'Taiwan' },
{ code: 'TJ', name: 'Tajikistan' },
{ code: 'TZ', name: 'Tanzania' },
{ code: 'TH', name: 'Thailand' },
{ code: 'TL', name: 'Timor-Leste' },
{ code: 'TG', name: 'Togo' },
{ code: 'TK', name: 'Tokelau' },
{ code: 'TO', name: 'Tonga' },
{ code: 'TT', name: 'Trinidad and Tobago' },
{ code: 'TN', name: 'Tunisia' },
{ code: 'TR', name: 'Turkey' },
{ code: 'TM', name: 'Turkmenistan' },
{ code: 'TC', name: 'Turks and Caicos Islands' },
{ code: 'TV', name: 'Tuvalu' },
{ code: 'UG', name: 'Uganda' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'US', name: 'United States' },
{ code: 'UM', name: 'United States Outlying Islands' },
{ code: 'UY', name: 'Uruguay' },
{ code: 'UZ', name: 'Uzbekistan' },
{ code: 'VU', name: 'Vanuatu' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VN', name: 'Vietnam' },
{ code: 'VG', name: 'Virgin Islands, British' },
{ code: 'VI', name: 'Virgin Islands, U.S.' },
{ code: 'WF', name: 'Wallis and Futuna' },
{ code: 'EH', name: 'Western Sahara' },
{ code: 'YE', name: 'Yemen' },
{ code: 'ZM', name: 'Zambia' },
{ code: 'ZW', name: 'Zimbabwe' },
]
}
}