DocumentationExtension Development

Extension Development Guide

A comprehensive guide for creating meaningful extensions for the SpotiFLAC ecosystem.

A complete guide for creating SpotiFLAC extensions.


Introduction

SpotiFLAC extensions allow you to add:

  • Metadata Provider: New track/album/artist search sources
  • Download Provider: New audio download sources

Extensions are written in JavaScript and run in a secure sandbox.

Requirements

  • Basic JavaScript knowledge
  • Text editor (VS Code, Notepad++, etc.)
  • Tool for creating ZIP files

Extension Structure

An extension is a ZIP file with the .spotiflac-ext extension containing:

text
my-extension.spotiflac-ext (ZIP)
├── manifest.json      # Required: Metadata and configuration
├── main.js           # Required: Main JavaScript code
└── icon.png          # Optional: Extension icon (PNG, 128x128 recommended)

Manifest File

The manifest.json file contains extension metadata and configuration.

Complete Manifest Example

json
{
  "name": "my-music-provider",
  "displayName": "My Music Provider",
  "version": "1.0.0",
  "description": "Extension for downloading from MyMusic service",
  "author": "Your Name",
  "homepage": "https://github.com/username/my-extension",
  "icon": "icon.png",

  "permissions": {
    "network": ["api.mymusic.com", "cdn.mymusic.com"],
    "storage": true,
    "file": true
  },

  "type": ["metadata_provider", "download_provider"],
  
  "skipMetadataEnrichment": false,
  "skipBuiltInFallback": false,

  "qualityOptions": [
    {
      "id": "LOSSLESS",
      "label": "FLAC Lossless",
      "description": "16-bit / 44.1kHz"
    },
    {
      "id": "MP3_320",
      "label": "MP3 320kbps",
      "description": "High quality MP3"
    },
    {
      "id": "OPUS_128",
      "label": "Opus 128kbps",
      "description": "Efficient audio codec"
    }
  ],

  "settings": [
    {
      "key": "apiKey",
      "label": "API Key",
      "type": "string",
      "description": "API key from MyMusic",
      "required": true
    },
    {
      "key": "quality",
      "label": "Audio Quality",
      "type": "select",
      "options": ["LOSSLESS", "HIGH", "NORMAL"],
      "default": "LOSSLESS"
    },
    {
      "key": "enableCache",
      "label": "Enable Cache",
      "type": "boolean",
      "default": true
    }
  ]
}

Manifest Fields

FieldTypeRequiredDescription
namestringYesUnique extension ID (lowercase, no spaces)
displayNamestringYesDisplay name for the extension
versionstringYesVersion (format: x.y.z)
descriptionstringYesShort description
authorstringYesCreator name
homepagestringNoHomepage/repository URL
iconstringNoIcon filename (e.g., "icon.png")
permissionsobjectYesAccess rights definition (network, storage)
typearrayYesExtension type (metadata_provider, download_provider)
settingsarrayNoUser configuration
qualityOptionsarrayNoCustom quality options for download providers (see below)
skipMetadataEnrichmentbooleanNoIf true, skip metadata enrichment from Deezer/Spotify (use metadata from extension)
skipBuiltInFallbackbooleanNoIf true, don't fallback to built-in providers (Tidal/Qobuz/Amazon) when extension download fails
minAppVersionstringNoMinimum SpotiFLAC version required (e.g., "1.0.0")
searchBehaviorobjectNoCustom search behavior configuration (see below)
urlHandlerobjectNoCustom URL handling configuration (see below)
trackMatchingobjectNoCustom track matching configuration (see below)
postProcessingobjectNoPost-processing hooks configuration (see below)

Quality Options

For download provider extensions, you can define custom quality options that will be shown in the quality picker UI. This is useful when your service offers different formats than the built-in providers (e.g., YouTube offers MP3/Opus instead of FLAC).

json
"qualityOptions": [
  {
    "id": "MP3_320",
    "label": "MP3 320kbps",
    "description": "High quality MP3"
  },
  {
    "id": "OPUS_128",
    "label": "Opus 128kbps",
    "description": "Efficient audio codec"
  },
  {
    "id": "AAC_256",
    "label": "AAC 256kbps",
    "description": "Apple audio format"
  }
]

Quality Option Fields:

FieldTypeRequiredDescription
idstringYesUnique identifier passed to download function
labelstringYesDisplay name shown in the UI
descriptionstringNoAdditional info (e.g., bitrate, format)
settingsarrayNoQuality-specific settings (see below)

If qualityOptions is not specified, a default "Default Quality" option will be shown.

Quality-Specific Settings

Each quality option can have its own settings. This is useful when different quality tiers require different API configurations (e.g., different endpoints, API keys, or parameters).

json
"qualityOptions": [
  {
    "id": "PREMIUM_FLAC",
    "label": "Premium FLAC",
    "description": "24-bit Hi-Res (requires premium)",
    "settings": [
      {
        "key": "premium_api_key",
        "type": "string",
        "label": "Premium API Key",
        "description": "API key for premium tier access",
        "required": true,
        "secret": true
      },
      {
        "key": "premium_endpoint",
        "type": "string",
        "label": "Premium Endpoint",
        "default": "https://api.example.com/premium/stream"
      }
    ]
  },
  {
    "id": "FREE_MP3",
    "label": "Free MP3",
    "description": "128kbps (free tier)",
    "settings": [
      {
        "key": "free_endpoint",
        "type": "string",
        "label": "Free Endpoint",
        "default": "https://api.example.com/free/stream"
      }
    ]
  }
]

In your extension code, access quality-specific settings like this:

javascript
function download(trackId, quality, outputPath, progressCallback) {
  // Get quality-specific settings
  const qualitySettings = settings.qualitySettings?.[quality] || {};
  
  let endpoint;
  let apiKey;
  
  if (quality === 'PREMIUM_FLAC') {
    endpoint = qualitySettings.premium_endpoint || 'https://api.example.com/premium/stream';
    apiKey = qualitySettings.premium_api_key;
    if (!apiKey) {
      return { success: false, error: 'Premium API key required', error_type: 'auth_error' };
    }
  } else {
    endpoint = qualitySettings.free_endpoint || 'https://api.example.com/free/stream';
    apiKey = settings.api_key; // Use global API key for free tier
  }
  
  // ... download logic using endpoint and apiKey
}

Quality-Specific Setting Fields:

FieldTypeRequiredDescription
keystringYesSetting key (accessed via settings.qualitySettings[quality][key])
typestringYesstring, number, boolean, or select
labelstringYesDisplay name in settings UI
descriptionstringNoHelp text for the setting
requiredbooleanNoWhether the setting is required
secretbooleanNoIf true, input will be masked (for API keys)
defaultanyNoDefault value
optionsarrayNoOptions for select type

Permissions

Extensions must declare the resources they need:

json
"permissions": {
  "network": [
    "api.example.com",    // HTTP access to specific domain
    "*.example.com"       // Wildcard subdomain
  ],
  "storage": true,        // Storage API access (for caching, settings)
  "file": true            // File API access (for downloads, file operations)
}

Permission Types:

PermissionTypeDescription
networkarrayList of allowed domains for HTTP requests
storagebooleanAccess to key-value storage API
filebooleanAccess to file operations (read, write, download)

Important Notes:

  • Only declared domains can be accessed via HTTP
  • Requests to other domains will be blocked
  • File operations are sandboxed to extension's data directory
  • Absolute paths are blocked for security (only relative paths allowed)
  • Download providers should set file: true to save downloaded files

Extension Types

Specify the features provided by the extension through the type field:

json
"type": [
  "metadata_provider",   // Provides search/metadata
  "download_provider"    // Provides downloads
]

Settings

Define user-configurable settings:

json
"settings": [
  {
    "key": "username",
    "label": "Username",
    "type": "string",
    "description": "Your account username",
    "required": true
  },
  {
    "key": "region",
    "label": "Region",
    "type": "select",
    "options": ["ID", "US", "JP", "UK"],
    "default": "ID"
  },
  {
    "key": "debug",
    "label": "Debug Mode",
    "type": "boolean",
    "default": false
  },
  {
    "key": "maxRetries",
    "label": "Max Retries",
    "type": "number",
    "default": 3
  }
]

Setting Types:

  • string: Text input
  • number: Number input
  • boolean: On/off toggle
  • select: Dropdown selection (requires options)

Setting Fields:

FieldTypeRequiredDescription
keystringYesUnique setting key (used in code)
labelstringYesDisplay name in settings UI
typestringYesstring, number, boolean, or select
descriptionstringNoHelp text for the setting
requiredbooleanNoWhether the setting is required
secretbooleanNoIf true, input will be masked (for passwords/API keys)
defaultanyNoDefault value
optionsarrayNoOptions for select type

Button Setting Type

The button type allows extensions to trigger JavaScript functions directly from the settings page. This is useful for actions like OAuth login, clearing cache, or running maintenance tasks.

json
"settings": [
  {
    "key": "login_button",
    "label": "Login to Service",
    "type": "button",
    "description": "Click to authenticate with your account",
    "action": "startLogin"
  },
  {
    "key": "clear_cache",
    "label": "Clear Cache",
    "type": "button",
    "description": "Remove all cached data",
    "action": "clearCache"
  }
]

Button-specific fields:

FieldTypeRequiredDescription
actionstringYesName of the JavaScript function to call

Implementing button actions in your extension:

javascript
// In your extension's index.js
function startLogin() {
  // Start OAuth flow
  auth.startOAuthWithPKCE({
    authUrl: "https://accounts.example.com/authorize",
    tokenUrl: "https://accounts.example.com/token",
    clientId: settings.clientId,
    scopes: ["streaming", "user-read-private"],
    redirectUri: "spotiflac://auth/callback"
  });
  
  return { success: true, message: "Opening login page..." };
}

function clearCache() {
  storage.clear();
  return { success: true, message: "Cache cleared!" };
}

// Register the action functions
registerExtension({
  initialize: initialize,
  cleanup: cleanup,
  startLogin: startLogin,     // Button action
  clearCache: clearCache,     // Button action
  // ... other functions
});

Return format for button actions:

javascript
// Success
{ success: true, message: "Optional success message" }

// Error
{ success: false, error: "Error description" }

Example with secret field (for API keys/passwords):

json
"settings": [
  {
    "key": "api_key",
    "label": "API Key",
    "type": "string",
    "description": "Your API key from the service",
    "required": true,
    "secret": true
  }
]

Custom Search Behavior

Extensions can provide custom search functionality (e.g., search YouTube directly):

json
"searchBehavior": {
  "enabled": true,
  "placeholder": "Search YouTube...",
  "primary": false,
  "icon": "youtube.png",
  "thumbnailRatio": "wide",
  "thumbnailWidth": 100,
  "thumbnailHeight": 56
}
FieldTypeDescription
enabledbooleanWhether extension provides custom search
placeholderstringPlaceholder text for search box
primarybooleanIf true, show as primary search tab
iconstringIcon for search tab
thumbnailRatiostringThumbnail aspect ratio preset (see below)
thumbnailWidthnumberCustom thumbnail width in pixels (optional)
thumbnailHeightnumberCustom thumbnail height in pixels (optional)

Thumbnail Ratio Presets

The thumbnailRatio field controls the aspect ratio of track thumbnails in search results. This is useful when your source uses different thumbnail dimensions than standard album art.

ValueAspect RatioUse Case
"square"1:1Album art, Spotify, Deezer (default)
"wide"16:9YouTube, video platforms
"portrait"2:3Poster-style, vertical thumbnails

Example for YouTube-style thumbnails:

json
"searchBehavior": {
  "enabled": true,
  "placeholder": "Search YouTube...",
  "thumbnailRatio": "wide"
}

Custom dimensions (overrides ratio preset):

json
"searchBehavior": {
  "enabled": true,
  "thumbnailWidth": 120,
  "thumbnailHeight": 68
}

When enabled, implement the customSearch function in your extension:

javascript
function customSearch(query, options) {
  // Search your platform
  const results = http.get(`https://api.example.com/search?q=${encodeURIComponent(query)}`);
  // Return array of track objects
  return JSON.parse(results.body).tracks.map(t => ({
    id: t.id,
    name: t.title,
    artists: t.artist,
    album_name: t.album,
    duration_ms: t.duration * 1000,
    images: t.thumbnail  // Thumbnail URL (will use thumbnailRatio for display)
  }));
}

Note: The images field in the returned track objects will be displayed using the thumbnailRatio setting from your manifest. For YouTube-style results, use "thumbnailRatio": "wide" to display 16:9 thumbnails.

Custom URL Handler

Extensions can register custom URL patterns to handle links from platforms like YouTube Music, SoundCloud, etc. When a user pastes or shares a URL that matches your pattern, SpotiFLAC will call your extension to handle it.

json
"urlHandler": {
  "enabled": true,
  "patterns": [
    "music.youtube.com",
    "youtube.com/watch",
    "youtu.be"
  ]
}
FieldTypeDescription
enabledbooleanWhether extension handles custom URLs
patternsarrayURL patterns to match (domain or path fragments)

Example patterns for common platforms:

json
// YouTube Music
"patterns": ["music.youtube.com", "youtube.com/watch", "youtu.be"]

// SoundCloud
"patterns": ["soundcloud.com"]

// Bandcamp
"patterns": ["bandcamp.com"]

When enabled, implement the handleURL function in your extension:

javascript
/**
 * Handle a URL from the user
 * @param {string} url - The full URL to handle
 * @returns {Object} Track, Album, or Artist metadata
 */
function handleURL(url) {
  // Parse the URL to determine content type
  const urlType = detectUrlType(url);
  
  if (urlType === 'track') {
    return handleTrackUrl(url);
  } else if (urlType === 'album') {
    return handleAlbumUrl(url);
  } else if (urlType === 'artist') {
    return handleArtistUrl(url);
  }
  
  return {
    success: false,
    error: "Unsupported URL type"
  };
}

// Return a single track
function handleTrackUrl(url) {
  const trackId = extractTrackId(url);
  const data = fetchTrackData(trackId);
  
  return {
    success: true,
    type: "track",  // Optional, defaults to "track"
    track: {
      id: data.id,
      name: data.title,
      artists: data.artist,
      album_name: data.album || "Unknown Album",
      duration_ms: data.duration * 1000,
      images: data.thumbnail
    }
  };
}

// Return an album with tracks
function handleAlbumUrl(url) {
  const albumId = extractAlbumId(url);
  const data = fetchAlbumData(albumId);
  
  return {
    success: true,
    type: "album",
    album: {
      id: data.id,
      name: data.title,
      artists: data.artist,
      release_date: data.releaseDate,
      total_tracks: data.tracks.length,
      images: data.cover,
      album_type: data.type,  // "album", "single", "compilation"
      tracks: data.tracks.map(t => ({
        id: t.id,
        name: t.title,
        artists: t.artist,
        album_name: data.title,
        duration_ms: t.duration * 1000,
        track_number: t.trackNumber,
        disc_number: t.discNumber || 1,
        isrc: t.isrc
      }))
    }
  };
}

// Return an artist with albums
function handleArtistUrl(url) {
  const artistId = extractArtistId(url);
  const data = fetchArtistData(artistId);
  
  return {
    success: true,
    type: "artist",
    artist: {
      id: data.id,
      name: data.name,
      image_url: data.picture,
      albums: data.albums.map(a => ({
        id: a.id,
        name: a.title,
        artists: data.name,
        release_date: a.releaseDate,
        total_tracks: a.trackCount,
        images: a.cover,
        album_type: a.type  // "album", "single", "compilation"
      }))
    }
  };
}

Return Types:

TypeDescriptionRequired Fields
trackSingle tracktrack.id, track.name, track.artists
albumAlbum with tracksalbum.id, album.name, album.tracks[]
artistArtist with albumsartist.id, artist.name, artist.albums[]

Important: Don't forget to register the handleURL function:

javascript
registerExtension({
  initialize: initialize,
  cleanup: cleanup,
  handleURL: handleURL,  // Add this!
  // ... other functions
});

URL Handler Flow:

  1. User pastes/shares a URL (e.g., https://music.youtube.com/watch?v=abc123)
  2. SpotiFLAC checks if any extension's patterns match the URL
  3. If matched, calls the extension's handleURL(url) function
  4. Extension returns track/album/artist metadata based on type field
  5. SpotiFLAC navigates to appropriate screen (track detail, album, or artist page)

Album & Playlist Functions (v3.0.1+)

Extensions can provide album/playlist tracks for the search results. When your customSearch returns items with item_type: "album" or item_type: "playlist", users can tap on them to view the track list.

Manifest requirements:

json
{
  "minAppVersion": "3.0.1",
  "type": ["metadata_provider"]
}

Search result with album/playlist items:

javascript
function customSearch(query, options) {
  const results = searchAPI(query);
  
  return results.map(item => {
    if (item.type === 'track') {
      return {
        id: item.id,
        name: item.title,
        artists: item.artist,
        album_name: item.album,
        duration_ms: item.duration * 1000,
        cover_url: item.thumbnail,
        item_type: "track"  // Optional, default
      };
    } else if (item.type === 'album' || item.type === 'ep' || item.type === 'single') {
      return {
        id: item.id,              // Album/browse ID
        name: item.title,
        artists: item.artist,
        album_name: item.title,   // Same as name for albums
        album_type: item.type,    // "album", "ep", "single", "playlist"
        release_date: item.year,
        cover_url: item.thumbnail,
        item_type: "album"        // REQUIRED for albums
      };
    } else if (item.type === 'playlist') {
      return {
        id: item.id,              // Playlist ID
        name: item.title,
        artists: item.owner,      // Playlist owner
        album_name: item.title,
        album_type: "playlist",
        cover_url: item.thumbnail,
        item_type: "playlist"     // REQUIRED for playlists
      };
    }
  });
}

Implement getAlbum and getPlaylist functions:

javascript
/**
 * Fetch album tracks by ID
 * @param {string} albumId - Album ID from search result
 * @returns {Object} Album with tracks array
 */
function getAlbum(albumId) {
  const data = fetchAlbumData(albumId);
  
  return {
    id: albumId,
    name: data.title,
    artists: data.artist,
    cover_url: data.thumbnail,
    release_date: data.year,
    total_tracks: data.tracks.length,
    album_type: data.type,  // "album", "ep", "single"
    tracks: data.tracks.map(t => ({
      id: t.id,
      name: t.title,
      artists: t.artist,
      album_name: data.title,
      duration_ms: t.duration * 1000,
      cover_url: t.thumbnail || data.thumbnail,
      track_number: t.trackNumber,
      provider_id: "your-extension-id"
    })),
    provider_id: "your-extension-id"
  };
}

/**
 * Fetch playlist tracks by ID
 * @param {string} playlistId - Playlist ID from search result
 * @returns {Object} Playlist with tracks array
 */
function getPlaylist(playlistId) {
  const data = fetchPlaylistData(playlistId);
  
  return {
    id: playlistId,
    name: data.title,
    owner: data.owner,
    cover_url: data.thumbnail,
    total_tracks: data.tracks.length,
    tracks: data.tracks.map(t => ({
      id: t.id,
      name: t.title,
      artists: t.artist,
      album_name: t.album || data.title,
      duration_ms: t.duration * 1000,
      cover_url: t.thumbnail,
      provider_id: "your-extension-id"
    })),
    provider_id: "your-extension-id"
  };
}

// Register functions
registerExtension({
  initialize: initialize,
  customSearch: customSearch,
  getAlbum: getAlbum,       // Required for album support
  getPlaylist: getPlaylist, // Required for playlist support
  // ... other functions
});

Return schema for getAlbum:

FieldTypeRequiredDescription
idstringYesAlbum ID
namestringYesAlbum title
artistsstringYesArtist name(s)
cover_urlstringNoAlbum artwork URL
release_datestringNoRelease date (YYYY or YYYY-MM-DD)
total_tracksnumberNoNumber of tracks
album_typestringNo"album", "ep", "single"
tracksarrayYesArray of track objects
provider_idstringYesYour extension ID

Return schema for getPlaylist:

FieldTypeRequiredDescription
idstringYesPlaylist ID
namestringYesPlaylist title
ownerstringNoPlaylist owner/creator
cover_urlstringNoPlaylist cover URL
total_tracksnumberNoNumber of tracks
tracksarrayYesArray of track objects
provider_idstringYesYour extension ID

Flow:

  1. User searches → customSearch() returns tracks + albums/playlists with item_type
  2. Search results show mixed items (tracks show duration, albums show "Album • Artist • Year")
  3. User taps album/playlist → SpotiFLAC calls getAlbum(id) or getPlaylist(id)
  4. Extension fetches and returns track list
  5. SpotiFLAC displays tracks, user can download them

Artist Support

Extensions can support artist pages by returning artist items from customSearch() and implementing getArtist():

Return artist items from customSearch:

javascript
function customSearch(query) {
  const results = searchAPI(query);
  
  return results.map(item => {
    if (item.type === "artist") {
      return {
        id: item.id,
        name: item.name,
        artists: item.name,      // Artist name in artists field for consistency
        cover_url: item.thumbnail,
        item_type: "artist"      // REQUIRED for artist items
      };
    }
    // ... handle tracks, albums, playlists
  });
}

Implement getArtist function:

javascript
/**
 * Fetch artist info and albums by ID
 * @param {string} artistId - Artist ID from search result
 * @returns {Object} Artist info with albums array
 */
function getArtist(artistId) {
  const data = fetchArtistData(artistId);
  
  return {
    id: artistId,
    name: data.name,
    image_url: data.thumbnail,
    albums: data.albums.map(album => ({
      id: album.id,
      name: album.title,
      artists: data.name,
      cover_url: album.thumbnail,
      release_date: album.year,
      total_tracks: album.trackCount || 0,
      album_type: album.type || "album",  // "album", "ep", "single"
      provider_id: "your-extension-id"
    })),
    provider_id: "your-extension-id"
  };
}

// Register function
registerExtension({
  // ... other functions
  getArtist: getArtist,  // Required for artist support
});

Return schema for getArtist:

FieldTypeRequiredDescription
idstringYesArtist ID
namestringYesArtist name
image_urlstringNoArtist image URL
albumsarrayYesArray of album objects (see album schema)
provider_idstringYesYour extension ID

Album object schema (within albums array):

FieldTypeRequiredDescription
idstringYesAlbum ID
namestringYesAlbum title
artistsstringNoArtist name(s)
cover_urlstringNoAlbum artwork URL
release_datestringNoRelease date (YYYY or YYYY-MM-DD)
total_tracksnumberNoNumber of tracks
album_typestringNo"album", "ep", "single", "compilation"
provider_idstringYesYour extension ID

Track Enrichment

Extensions can enrich track metadata before download using enrichTrack(). This is useful for:

  • Adding ISRC codes from external APIs (e.g., Odesli/song.link)
  • Getting links to other streaming services for fallback downloads
  • Enriching metadata with additional info
javascript
/**
 * Enrich track metadata before download
 * @param {Object} track - Track object from search/album/playlist
 * @returns {Object} Enriched track object
 */
function enrichTrack(track) {
  if (!track || !track.id) {
    return track;
  }
  
  // Example: Use Odesli API to get ISRC and external links
  const ytUrl = "https://music.youtube.com/watch?v=" + encodeURIComponent(track.id);
  const odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(ytUrl);
  
  try {
    const res = fetch(odesliUrl, { method: "GET" });
    if (!res || !res.ok) {
      return track;
    }
    
    const data = res.json();
    const enrichment = {};
    
    // Extract ISRC from entities
    if (data.entitiesByUniqueId) {
      for (const key of Object.keys(data.entitiesByUniqueId)) {
        const entity = data.entitiesByUniqueId[key];
        if (entity && entity.isrc) {
          enrichment.isrc = entity.isrc;
          break;
        }
      }
    }
    
    // Extract external links for fallback downloads
    if (data.linksByPlatform) {
      enrichment.external_links = {};
      
      if (data.linksByPlatform.deezer) {
        enrichment.external_links.deezer = data.linksByPlatform.deezer.url;
        // Extract Deezer track ID
        const match = data.linksByPlatform.deezer.url.match(/\/track\/(\d+)/);
        if (match) enrichment.deezer_id = match[1];
      }
      if (data.linksByPlatform.tidal) {
        enrichment.external_links.tidal = data.linksByPlatform.tidal.url;
      }
      if (data.linksByPlatform.spotify) {
        enrichment.external_links.spotify = data.linksByPlatform.spotify.url;
      }
    }
    
    return Object.assign({}, track, enrichment);
  } catch (e) {
    log.error("enrichTrack failed", e);
    return track;
  }
}

// Register function
registerExtension({
  // ... other functions
  enrichTrack: enrichTrack,  // Optional: enrich tracks before download
});

Enriched track fields:

FieldTypeDescription
isrcstringInternational Standard Recording Code
tidal_idstringTidal track ID for direct download (skip search)
qobuz_idstringQobuz track ID for direct download (skip search)
deezer_idstringDeezer track ID for fallback
spotify_idstringSpotify track ID for fallback
external_linksobjectMap of service → URL
external_links.tidalstringTidal track URL
external_links.qobuzstringQobuz track URL
external_links.deezerstringDeezer track URL
external_links.spotifystringSpotify track URL
external_links.applestringApple Music track URL

How enrichment enables high-quality downloads:

When your extension provides tidal_id or qobuz_id, SpotiFLAC can download lossless audio without searching. This is the recommended approach for extensions that don't provide their own audio source.

text
Extension Search (YouTube Music, SoundCloud, etc.)
       │
       ▼
   enrichTrack() called before download
       │
       ▼
   Odesli API returns: tidal_id, qobuz_id, isrc
       │
       ▼
   SpotiFLAC downloads from Tidal/Qobuz using direct ID
       │
       ▼
   High-quality FLAC/MQA audio (no search needed!)

Important: This enrichment flow only applies to extension tracks. Normal Spotify/Deezer downloads are not affected and continue using their standard flow.

Download priority with enrichment:

  1. tidal_id → Direct Tidal download (highest priority, lossless/MQA)
  2. qobuz_id → Direct Qobuz download (Hi-Res FLAC up to 24-bit/192kHz)
  3. ISRC search → Search Tidal/Qobuz by ISRC code
  4. Metadata search → Search by track name/artist (last resort)

Custom Track Matching

Extensions can override the default ISRC-based track matching:

json
"trackMatching": {
  "customMatching": true,
  "strategy": "custom",
  "durationTolerance": 5
}
FieldTypeDescription
customMatchingbooleanWhether extension handles matching
strategystring"isrc", "name", "duration", or "custom"
durationTolerancenumberTolerance in seconds for duration matching

When enabled, implement the matchTrack function:

javascript
function matchTrack(sourceTrack, candidates) {
  // sourceTrack: { name, artists, duration_ms, isrc, ... }
  // candidates: array of tracks from your search
  
  // Use built-in matching helpers
  const normalizedSource = matching.normalizeString(sourceTrack.name);
  
  for (const candidate of candidates) {
    const normalizedCandidate = matching.normalizeString(candidate.name);
    const similarity = matching.compareStrings(normalizedSource, normalizedCandidate);
    const durationMatch = matching.compareDuration(sourceTrack.duration_ms, candidate.duration_ms, 3000);
    
    if (similarity > 0.8 && durationMatch) {
      return {
        matched: true,
        track_id: candidate.id,
        confidence: similarity
      };
    }
  }
  
  return { matched: false, reason: "No match found" };
}

Post-Processing Hooks

Extensions can modify files after download (convert format, normalize audio, etc.):

json
"postProcessing": {
  "enabled": true,
  "hooks": [
    {
      "id": "convert_mp3",
      "name": "Convert to MP3",
      "description": "Convert FLAC to MP3 320kbps",
      "defaultEnabled": false,
      "supportedFormats": ["flac"]
    },
    {
      "id": "normalize",
      "name": "Normalize Audio",
      "description": "Apply ReplayGain normalization",
      "defaultEnabled": true,
      "supportedFormats": ["flac", "mp3"]
    }
  ]
}
FieldTypeDescription
enabledbooleanWhether extension provides post-processing
hooksarrayList of available hooks
hooks[].idstringUnique hook identifier
hooks[].namestringDisplay name
hooks[].descriptionstringDescription
hooks[].defaultEnabledbooleanWhether enabled by default
hooks[].supportedFormatsarraySupported file formats

Implement the postProcess function:

javascript
function postProcess(filePath, metadata, hookId) {
  if (hookId === 'convert_mp3') {
    const outputPath = filePath.replace('.flac', '.mp3');
    
    // Use FFmpeg API
    const result = ffmpeg.convert(filePath, outputPath, {
      codec: 'libmp3lame',
      bitrate: '320k'
    });
    
    if (result.success) {
      // Delete original file
      file.delete(filePath);
      return { success: true, new_file_path: outputPath };
    }
    return { success: false, error: result.error };
  }
  
  if (hookId === 'normalize') {
    // Apply ReplayGain
    const result = ffmpeg.execute(`-i "${filePath}" -af "loudnorm" -y "${filePath}.tmp"`);
    if (result.success) {
      file.move(filePath + '.tmp', filePath);
      return { success: true, new_file_path: filePath };
    }
    return { success: false, error: result.error };
  }
  
  return { success: true, new_file_path: filePath };
}

Main Script

The main.js file (or index.js) contains the extension's JavaScript code.

Basic Structure

javascript
// ============================================
// Extension: My Music Provider
// Version: 1.0.0
// ============================================

// Global variable to store settings
let settings = {};

// ============================================
// LIFECYCLE HOOKS (Required)
// ============================================

/**
 * Called when extension is loaded
 * @param {Object} config - User settings
 */
function initialize(config) {
  settings = config || {};
  log.info("Extension initialized with settings:", settings);

  // Validate required settings
  if (!settings.apiKey) {
    throw new Error("API Key is required");
  }

  return true;
}

/**
 * Called when extension is unloaded
 */
function cleanup() {
  log.info("Extension cleanup");
  // Clean up resources if any
}

// ============================================
// METADATA PROVIDER (Optional)
// ============================================

/**
 * Search tracks by query
 * @param {string} query - Search query
 * @param {number} limit - Max results
 * @returns {Array} Array of track objects
 */
function searchTracks(query, limit) {
  log.debug("Searching:", query);

  const response = http.get("https://api.mymusic.com/search", {
    params: {
      q: query,
      type: "track",
      limit: limit
    },
    headers: {
      "Authorization": "Bearer " + settings.apiKey
    }
  });

  if (!response.ok) {
    log.error("Search failed:", response.status);
    return [];
  }

  const data = JSON.parse(response.body);

  // Transform to SpotiFLAC format
  return data.tracks.map(track => ({
    id: track.id,
    name: track.title,
    artists: track.artist.name,
    album_name: track.album.title,
    album_artist: track.album.artist.name,
    isrc: track.isrc,
    duration_ms: track.duration * 1000,
    track_number: track.trackNumber,
    disc_number: track.discNumber || 1,
    release_date: track.album.releaseDate,
    images: track.album.cover
  }));
}


/**
 * Get track detail by ID
 * @param {string} trackId - Track ID
 * @returns {Object} Track object
 */
function getTrack(trackId) {
  const response = http.get("https://api.mymusic.com/tracks/" + trackId, {
    headers: {
      "Authorization": "Bearer " + settings.apiKey
    }
  });

  if (!response.ok) {
    return null;
  }

  const track = JSON.parse(response.body);

  return {
    id: track.id,
    name: track.title,
    artists: track.artist.name,
    album_name: track.album.title,
    album_artist: track.album.artist.name,
    isrc: track.isrc,
    duration_ms: track.duration * 1000,
    track_number: track.trackNumber,
    disc_number: track.discNumber || 1,
    release_date: track.album.releaseDate,
    images: track.album.cover
  };
}

/**
 * Get album detail by ID
 * @param {string} albumId - Album ID
 * @returns {Object} Album object with tracks
 */
function getAlbum(albumId) {
  const response = http.get("https://api.mymusic.com/albums/" + albumId, {
    headers: {
      "Authorization": "Bearer " + settings.apiKey
    }
  });

  if (!response.ok) {
    return null;
  }

  const album = JSON.parse(response.body);

  return {
    id: album.id,
    name: album.title,
    artists: album.artist.name,
    release_date: album.releaseDate,
    total_tracks: album.trackCount,
    images: album.cover,
    tracks: album.tracks.map(track => ({
      id: track.id,
      name: track.title,
      artists: track.artist.name,
      album_name: album.title,
      isrc: track.isrc,
      duration_ms: track.duration * 1000,
      track_number: track.trackNumber,
      disc_number: track.discNumber || 1
    }))
  };
}

/**
 * Get artist detail by ID
 * @param {string} artistId - Artist ID
 * @returns {Object} Artist object
 */
function getArtist(artistId) {
  const response = http.get("https://api.mymusic.com/artists/" + artistId, {
    headers: {
      "Authorization": "Bearer " + settings.apiKey
    }
  });

  if (!response.ok) {
    return null;
  }

  const artist = JSON.parse(response.body);

  return {
    id: artist.id,
    name: artist.name,
    images: artist.picture,
    albums: artist.albums.map(album => ({
      id: album.id,
      name: album.title,
      release_date: album.releaseDate,
      total_tracks: album.trackCount,
      images: album.cover,
      album_type: album.type
    }))
  };
}

/**
 * Enrich track metadata before download (lazy enrichment hook)
 * 
 * This function is called by the runtime just before download starts.
 * Use this to fetch expensive metadata (like real ISRC) that you don't
 * want to fetch upfront when loading playlists/albums.
 * 
 * Benefits:
 * - Playlists/albums load instantly without waiting for enrichment
 * - Only tracks that are actually downloaded get enriched
 * - Reduces API calls for tracks that are never downloaded
 * 
 * @param {Object} track - Track metadata object
 * @param {string} track.id - Track ID
 * @param {string} track.name - Track name
 * @param {string} track.artists - Artist name(s)
 * @param {string} track.isrc - Current ISRC (may be placeholder)
 * @param {number} track.duration_ms - Duration in milliseconds
 * @returns {Object} Enriched track metadata (or original if no enrichment needed)
 * 
 * @example
 * function enrichTrack(track) {
 *   // Only enrich if ISRC looks like a placeholder (e.g., Spotify ID)
 *   if (track.isrc && track.isrc.length === 22) {
 *     // Fetch real ISRC from external API
 *     const realISRC = fetchRealISRC(track.id);
 *     if (realISRC) {
 *       track.isrc = realISRC;
 *     }
 *   }
 *   return track;
 * }
 */
function enrichTrack(track) {
  // Example: Fetch real ISRC via SongLink -> Deezer
  if (track.isrc && track.isrc.length === 22) {
    // This looks like a Spotify ID, not a real ISRC
    const deezerUrl = getDeezerUrlFromSongLink(track.id);
    if (deezerUrl) {
      const realISRC = getISRCFromDeezer(deezerUrl);
      if (realISRC) {
        log.info("Enriched ISRC:", track.isrc, "->", realISRC);
        track.isrc = realISRC;
      }
    }
  }
  return track;
}

// ============================================
// ODESLI (SONG.LINK) INTEGRATION EXAMPLE
// ============================================
// The Odesli API is useful for:
// - Converting YouTube/SoundCloud tracks to ISRC
// - Finding the same track on Deezer/Tidal/Spotify
// - Enabling built-in service fallback for extensions that don't have ISRCs

/**
 * Example: Enrich YouTube Music tracks with ISRC via Odesli
 * @param {Object} track - Track metadata from extension
 * @returns {Object} Enriched track with ISRC and external links
 */
function enrichTrackWithOdesli(track) {
  if (!track || !track.id) return track;
  
  // Build YouTube Music URL for Odesli lookup
  var ytUrl = "https://music.youtube.com/watch?v=" + encodeURIComponent(track.id);
  var odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(ytUrl);
  
  try {
    var res = fetch(odesliUrl, {
      method: "GET",
      headers: {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
      }
    });
    
    if (!res || !res.ok) return track;
    
    var data = res.json();
    if (!data) return track;
    
    // Extract ISRC from entitiesByUniqueId
    if (data.entitiesByUniqueId) {
      var entities = data.entitiesByUniqueId;
      var entityKeys = Object.keys(entities);
      
      for (var i = 0; i < entityKeys.length; i++) {
        var entity = entities[entityKeys[i]];
        if (entity && entity.isrc) {
          track.isrc = entity.isrc;
          log.info("enrichTrack: found ISRC", track.isrc);
          break;
        }
      }
    }
    
    // Extract links to other services (optional)
    if (data.linksByPlatform) {
      var links = data.linksByPlatform;
      track.external_links = {};
      
      if (links.deezer && links.deezer.url) {
        track.external_links.deezer = links.deezer.url;
        // Extract Deezer track ID
        var deezerMatch = links.deezer.url.match(/\/track\/(\d+)/);
        if (deezerMatch) track.deezer_id = deezerMatch[1];
      }
      if (links.tidal && links.tidal.url) {
        track.external_links.tidal = links.tidal.url;
      }
      if (links.spotify && links.spotify.url) {
        track.external_links.spotify = links.spotify.url;
      }
    }
    
    return track;
  } catch (e) {
    log.error("enrichTrack: Odesli API error", String(e));
    return track;
  }
}

// Don't forget to add odesli.io/api.song.link to manifest permissions:
// "permissions": {
//   "network": ["api.song.link", "odesli.io", ...]
// }


// ============================================
// DOWNLOAD PROVIDER (Optional)
// ============================================

/**
 * Check if track is available for download
 * @param {string} isrc - ISRC code
 * @param {string} trackName - Track name (fallback)
 * @param {string} artistName - Artist name (fallback)
 * @returns {Object} Availability info
 */
function checkAvailability(isrc, trackName, artistName) {
  // Search track by ISRC
  let trackId = null;

  if (isrc) {
    const response = http.get("https://api.mymusic.com/search", {
      params: { isrc: isrc },
      headers: { "Authorization": "Bearer " + settings.apiKey }
    });

    if (response.ok) {
      const data = JSON.parse(response.body);
      if (data.tracks && data.tracks.length > 0) {
        trackId = data.tracks[0].id;
      }
    }
  }

  // Fallback: search by name
  if (!trackId) {
    const query = trackName + " " + artistName;
    const response = http.get("https://api.mymusic.com/search", {
      params: { q: query, type: "track", limit: 1 },
      headers: { "Authorization": "Bearer " + settings.apiKey }
    });

    if (response.ok) {
      const data = JSON.parse(response.body);
      if (data.tracks && data.tracks.length > 0) {
        trackId = data.tracks[0].id;
      }
    }
  }

  return {
    available: trackId !== null,
    track_id: trackId,
    quality: settings.quality || "LOSSLESS"
  };
}

/**
 * Get download URL for track
 * @param {string} trackId - Track ID from checkAvailability
 * @param {string} quality - Requested quality
 * @returns {Object} Download info
 */
function getDownloadUrl(trackId, quality) {
  const response = http.get("https://api.mymusic.com/tracks/" + trackId + "/stream", {
    params: { quality: quality },
    headers: { "Authorization": "Bearer " + settings.apiKey }
  });

  if (!response.ok) {
    return { success: false, error: "Failed to get stream URL" };
  }

  const data = JSON.parse(response.body);

  return {
    success: true,
    url: data.url,
    format: data.format,        // "flac", "mp3", "m4a"
    quality: data.quality,
    bit_depth: data.bitDepth,   // 16, 24
    sample_rate: data.sampleRate // 44100, 96000, etc
  };
}


/**
 * Download track to file
 * @param {Object} request - Download request
 * @param {Function} progressCallback - Progress callback
 * @returns {Object} Download result
 */
function download(request, progressCallback) {
  log.info("Downloading:", request.track_name);

  // 1. Check availability
  const availability = checkAvailability(
    request.isrc,
    request.track_name,
    request.artist_name
  );

  if (!availability.available) {
    return {
      success: false,
      error: "Track not available",
      error_type: "not_found"
    };
  }

  // 2. Get download URL
  const downloadInfo = getDownloadUrl(
    availability.track_id,
    request.quality || "LOSSLESS"
  );

  if (!downloadInfo.success) {
    return {
      success: false,
      error: downloadInfo.error,
      error_type: "stream_error"
    };
  }

  // 3. Build output filename
  const extension = downloadInfo.format === "flac" ? ".flac" : ".m4a";
  const filename = gobackend.sanitizeFilename(
    request.track_name + " - " + request.artist_name
  ) + extension;
  const outputPath = request.output_dir + "/" + filename;

  // 4. Download file with progress
  const result = file.download(downloadInfo.url, outputPath, {
    headers: {
      "Authorization": "Bearer " + settings.apiKey
    },
    onProgress: function(received, total) {
      const percent = total > 0 ? received / total : 0;
      progressCallback(percent);
    }
  });

  if (!result.success) {
    return {
      success: false,
      error: "Download failed: " + result.error,
      error_type: "download_error"
    };
  }

  // 5. Return success
  return {
    success: true,
    file_path: outputPath,
    format: downloadInfo.format,
    actual_bit_depth: downloadInfo.bit_depth,
    actual_sample_rate: downloadInfo.sample_rate
  };
}

// Export functions (required at end of file)
// SpotiFLAC will call these functions

// ============================================
// REGISTER EXTENSION (REQUIRED!)
// ============================================
// You MUST call registerExtension() at the end of your script
// to register your extension with SpotiFLAC.
// Pass an object containing all your provider functions.

registerExtension({
  // Lifecycle (required)
  initialize: initialize,
  cleanup: cleanup,
  
  // Metadata Provider functions (if type includes "metadata_provider")
  searchTracks: searchTracks,
  getTrack: getTrack,
  getAlbum: getAlbum,
  getArtist: getArtist,
  
  // Lazy enrichment hook (optional, called before download)
  enrichTrack: enrichTrack,
  
  // Download Provider functions (if type includes "download_provider")
  checkAvailability: checkAvailability,
  getDownloadUrl: getDownloadUrl,
  download: download
});

console.log("My Music Provider loaded!");

Important: registerExtension()

Every extension MUST call registerExtension() at the end of the script. This function registers your extension's functions with SpotiFLAC. Without this call, the extension will fail to load with the error: "extension did not call registerExtension()".

javascript
// Minimal example
registerExtension({
  initialize: function(config) { return true; },
  cleanup: function() {},
  searchTracks: function(query, limit) { return []; }
});

API Reference

HTTP API

The HTTP API provides full control over network requests with automatic cookie management.

javascript
// GET request
const response = http.get(url, headers);

// POST request - body can be string or object (auto-stringified to JSON)
const response = http.post(url, body, headers);

// PUT request - same signature as POST
const response = http.put(url, body, headers);

// DELETE request - no body
const response = http.delete(url, headers);

// PATCH request - same signature as POST
const response = http.patch(url, body, headers);

// Generic request (supports any HTTP method)
const response = http.request(url, {
  method: "POST",           // HTTP method (default: "GET")
  body: { key: "value" },   // Request body (string or object)
  headers: {                // Request headers
    "Authorization": "Bearer token",
    "Content-Type": "application/json"
  }
});

// Clear all cookies for this extension
http.clearCookies();

Request Headers

Headers are optional. If you provide a custom User-Agent, it will be used instead of the default.

javascript
// Custom User-Agent is respected
const response = http.get(url, {
  "User-Agent": "MyExtension/1.0",
  "Authorization": "Bearer token"
});

Response Object

javascript
{
  statusCode: 200,    // HTTP status code
  status: 200,        // Alias for statusCode
  ok: true,           // true if status code is 2xx
  body: "...",        // Response body as string
  headers: {          // Response headers
    "Content-Type": "application/json",
    "Set-Cookie": ["cookie1=value1", "cookie2=value2"]  // Arrays for multi-value headers
  }
}

// Example: Parse JSON response
const data = JSON.parse(response.body);
// Or use utils helper
const data = utils.parseJSON(response.body);

Form-Encoded POST (application/x-www-form-urlencoded)

For OAuth token exchanges and APIs that require form-encoded data:

javascript
// Method 1: Manual string building
const formBody = "grant_type=authorization_code" +
  "&client_id=" + encodeURIComponent(clientId) +
  "&code=" + encodeURIComponent(authCode) +
  "&redirect_uri=" + encodeURIComponent(redirectUri);

const response = http.post("https://api.example.com/oauth/token", formBody, {
  "Content-Type": "application/x-www-form-urlencoded"
});

// Method 2: Using URLSearchParams (browser-compatible)
const params = new URLSearchParams();
params.set("grant_type", "authorization_code");
params.set("client_id", clientId);
params.set("code", authCode);
params.set("redirect_uri", redirectUri);

const response = http.post("https://api.example.com/oauth/token", params.toString(), {
  "Content-Type": "application/x-www-form-urlencoded"
});

// Method 3: Helper function
function formEncode(obj) {
  return Object.keys(obj)
    .map(key => encodeURIComponent(key) + "=" + encodeURIComponent(obj[key]))
    .join("&");
}

const response = http.post("https://api.example.com/oauth/token", formEncode({
  grant_type: "authorization_code",
  client_id: clientId,
  code: authCode,
  redirect_uri: redirectUri
}), {
  "Content-Type": "application/x-www-form-urlencoded"
});

Important: When using form-encoded POST, you MUST set the Content-Type header to application/x-www-form-urlencoded. Otherwise, the default application/json will be used.

Cookie Jar

Each extension has its own persistent cookie jar. Cookies are automatically:

  • Stored when received via Set-Cookie headers
  • Sent with subsequent requests to the same domain
javascript
// First request - server sets cookies
http.get("https://api.example.com/login");

// Second request - cookies are automatically included
http.get("https://api.example.com/data");

// Clear cookies if needed (e.g., for logout)
http.clearCookies();

YouTube Music / Innertube API Example

For YouTube Music extensions, you need to declare all required domains in your manifest:

json
{
  "permissions": {
    "network": [
      "music.youtube.com",
      "www.youtube.com",
      "youtube.com",
      "youtubei.googleapis.com",
      "*.googlevideo.com",
      "*.youtube.com",
      "*.ytimg.com"
    ],
    "storage": true
  }
}

Example Innertube API call:

javascript
async function searchYouTubeMusic(query) {
  const visitorId = storage.get("visitorId") || "";
  
  const response = http.post("https://youtubei.googleapis.com/youtubei/v1/search", {
    query: query,
    context: {
      client: {
        clientName: "WEB_REMIX",
        clientVersion: "1.20240101.01.00"
      }
    }
  }, {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "X-Goog-Visitor-Id": visitorId,
    "X-Youtube-Client-Version": "1.20240101.01.00",
    "X-Youtube-Client-Name": "67"
  });
  
  // Save visitor ID from response headers for future requests
  const newVisitorId = response.headers["X-Goog-Visitor-Id"];
  if (newVisitorId) {
    storage.set("visitorId", newVisitorId);
  }
  
  if (!response.ok) {
    log.error("YouTube Music search failed:", response.statusCode);
    return [];
  }
  
  return JSON.parse(response.body);
}

Browser-like Polyfills

SpotiFLAC provides browser-compatible APIs to make porting web libraries easier. These polyfills work within the sandbox security model.

fetch() API

The global fetch() function provides a browser-compatible HTTP API:

javascript
// Basic GET request
const response = fetch("https://api.example.com/data");
const data = response.json();

// POST request with options
const response = fetch("https://api.example.com/search", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer token"
  },
  body: JSON.stringify({ query: "search term" })
});

if (response.ok) {
  const data = response.json();
  console.log(data);
} else {
  console.log("Error:", response.status, response.statusText);
}

Response Object:

javascript
{
  ok: true,              // true if status is 2xx
  status: 200,           // HTTP status code
  statusText: "OK",      // HTTP status text
  url: "...",            // Request URL
  headers: {},           // Response headers
  
  // Methods
  text(),                // Returns body as string
  json(),                // Parses body as JSON
  arrayBuffer()          // Returns body as byte array
}

Note: Unlike browser fetch, this is synchronous (not Promise-based) due to Goja VM limitations. However, the API signature is compatible for easier porting.

atob() / btoa()

Global Base64 encoding/decoding functions:

javascript
// Encode string to Base64
const encoded = btoa("Hello, World!");  // "SGVsbG8sIFdvcmxkIQ=="

// Decode Base64 to string
const decoded = atob("SGVsbG8sIFdvcmxkIQ==");  // "Hello, World!"

TextEncoder / TextDecoder

For encoding/decoding text to/from byte arrays:

javascript
// Encode string to bytes (UTF-8)
const encoder = new TextEncoder();
const bytes = encoder.encode("Hello");  // [72, 101, 108, 108, 111]

// Decode bytes to string
const decoder = new TextDecoder("utf-8");
const text = decoder.decode([72, 101, 108, 108, 111]);  // "Hello"

URL / URLSearchParams

For URL parsing and manipulation:

javascript
// Parse URL
const url = new URL("https://example.com/path?foo=bar&baz=qux");
console.log(url.hostname);    // "example.com"
console.log(url.pathname);    // "/path"
console.log(url.search);      // "?foo=bar&baz=qux"
console.log(url.searchParams.get("foo"));  // "bar"

// Build URL with query params
const params = new URLSearchParams();
params.set("query", "search term");
params.set("limit", "10");
const queryString = params.toString();  // "query=search+term&limit=10"

// Relative URL resolution
const base = new URL("https://example.com/api/");
const full = new URL("users/123", base);
console.log(full.href);  // "https://example.com/api/users/123"

Porting Browser Libraries

When porting browser libraries (like youtubei.js), you may need to:

  1. Bundle the library using Webpack, Rollup, or Esbuild to create a single file
  2. Replace unsupported APIs with SpotiFLAC equivalents:
    • fetch() → Already supported (synchronous version)
    • localStorage → Use storage.get/set
    • crypto.subtle → Use utils.md5/sha256 or credentials API
  3. Declare all domains in manifest permissions

Example bundler config (Rollup):

javascript
// rollup.config.js
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/index.js',
    format: 'iife',  // Immediately Invoked Function Expression
    name: 'MyExtension'
  }
};

Storage API

javascript
// Save data (persisted across app restarts)
storage.set("key", "value");
storage.set("config", { foo: "bar" });

// Get data
const value = storage.get("key");
const config = storage.get("config");

// Remove data
storage.remove("key");

File API

javascript
// Download file
const result = file.download(url, outputPath, {
  headers: {},
  onProgress: function (received, total) {
    // Progress callback
  },
});

// Check if file exists
const exists = file.exists(path);

// Read file content
const content = file.read(path);

// Write file
file.write(path, content);

// Delete file
file.delete(path);

Note: File operations are limited to the extension's data directory.

Logging API

javascript
log.debug("Debug message", data);
log.info("Info message", data);
log.warn("Warning message", data);
log.error("Error message", data);

Utility API

javascript
// JSON
const obj = utils.parseJSON(jsonString);
const str = utils.stringifyJSON(obj);

// Encoding
const encoded = utils.base64Encode(string);
const decoded = utils.base64Decode(encoded);

// Hashing
const md5Hash = utils.md5(string);
const sha256Hash = utils.sha256(string);

// HMAC (for API signatures and TOTP)
const signature = utils.hmacSHA256(message, secretKey);      // Returns hex string
const signatureB64 = utils.hmacSHA256Base64(message, secretKey);  // Returns base64 string
const hmacResult = utils.hmacSHA1(keyBytes, messageBytes);   // Returns array of bytes (for TOTP)

HMAC-SHA1 for TOTP

utils.hmacSHA1 is useful for implementing TOTP (Time-based One-Time Password) authentication:

javascript
function generateTOTP(secret, counter) {
  // Decode base32 secret to bytes
  const key = base32Decode(secret);
  
  // Convert counter to 8 bytes (big-endian)
  const counterBytes = [];
  let c = counter;
  for (let i = 7; i >= 0; i--) {
    counterBytes[i] = c & 0xff;
    c = Math.floor(c / 256);
  }
  
  // HMAC-SHA1 - key and message can be arrays of bytes
  const hmac = utils.hmacSHA1(key, counterBytes);
  
  // Dynamic truncation
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code = ((hmac[offset] & 0x7f) << 24) |
               ((hmac[offset + 1] & 0xff) << 16) |
               ((hmac[offset + 2] & 0xff) << 8) |
               (hmac[offset + 3] & 0xff);
  
  return (code % 1000000).toString().padStart(6, "0");
}

// Usage
const counter = Math.floor(Date.now() / 1000 / 30);
const totpCode = generateTOTP(base32Secret, counter);

HMAC-SHA256 Example (API Signing)

Many APIs require request signing using HMAC-SHA256. Here's a complete example:

javascript
function signRequest(method, path, timestamp, body, secretKey) {
  // Build string to sign (format varies by API)
  const stringToSign = [method, path, timestamp, body].join("\n");
  
  // Generate HMAC-SHA256 signature
  const signature = utils.hmacSHA256Base64(stringToSign, secretKey);
  
  return signature;
}

// Example: Signed API request
function makeSignedRequest(endpoint, data) {
  const timestamp = Date.now().toString();
  const body = JSON.stringify(data);
  const signature = signRequest("POST", endpoint, timestamp, body, settings.api_secret);
  
  return http.post("https://api.example.com" + endpoint, body, {
    "Content-Type": "application/json",
    "X-Timestamp": timestamp,
    "X-Signature": signature,
    "X-API-Key": settings.api_key
  });
}

Go Backend API

javascript
// Sanitize filename
const safe = gobackend.sanitizeFilename(filename);

// Get audio quality info from file
const quality = gobackend.getAudioQuality(filePath);
// returns object { bitDepth: 16, sampleRate: 44100, totalSamples: 12345 }
// or { error: "..." }

// Build filename from template
const filename = gobackend.buildFilename(template, metadata);
// metadata is object: { title, artist, album, track_number, ... }

Credentials API (Encrypted)

javascript
// Store sensitive data (encrypted on disk)
credentials.store("key", "value");
credentials.store("config", { apiKey: "...", secret: "..." });

// Get sensitive data (decrypted)
const value = credentials.get("key");
const config = credentials.get("config");

// Check if credential exists
const exists = credentials.has("key");

// Remove credential
credentials.remove("key");

Auth API (OAuth Support)

javascript
// Request app to open OAuth URL
auth.openAuthUrl(authUrl, callbackUrl);

// Get auth code (set by app after OAuth callback)
const code = auth.getAuthCode();

// Set auth tokens
auth.setAuthCode({
  code: "...",
  access_token: "...",
  refresh_token: "...",
  expires_in: 3600
});

// Check if authenticated
const isAuth = auth.isAuthenticated();

// Get current tokens
const tokens = auth.getTokens();
// { access_token, refresh_token, is_authenticated, expires_at, is_expired }

// Clear auth state (logout)
auth.clearAuth();

PKCE (Proof Key for Code Exchange) is the recommended OAuth flow for mobile/native apps. SpotiFLAC provides built-in PKCE support for secure OAuth authentication.

Quick Start (High-Level API)

javascript
// 1. Start OAuth with PKCE (generates verifier/challenge automatically)
const result = auth.startOAuthWithPKCE({
  authUrl: "https://accounts.spotify.com/authorize",
  clientId: "your_client_id",
  redirectUri: "spotiflac://callback",
  scope: "user-read-private playlist-read-private",
  extraParams: {
    show_dialog: "true"
  }
});

if (result.success) {
  log.info("OAuth URL opened:", result.authUrl);
  log.info("PKCE verifier stored for later use");
}

// 2. After user authorizes, get the auth code
const code = auth.getAuthCode();

// 3. Exchange code for tokens (uses stored PKCE verifier automatically)
const tokens = auth.exchangeCodeWithPKCE({
  tokenUrl: "https://accounts.spotify.com/api/token",
  clientId: "your_client_id",
  redirectUri: "spotiflac://callback",
  code: code
});

if (tokens.success) {
  log.info("Access token:", tokens.access_token);
  log.info("Refresh token:", tokens.refresh_token);
  // Tokens are automatically stored in auth state
}

Low-Level API (Manual Control)

javascript
// Generate PKCE pair manually
const pkce = auth.generatePKCE(64);  // Optional length (43-128, default: 64)
// { verifier: "...", challenge: "...", method: "S256" }

// Get stored PKCE (if previously generated)
const storedPKCE = auth.getPKCE();
// { verifier: "...", challenge: "...", method: "S256" } or {}

// Build your own OAuth URL with PKCE
const authUrl = `https://accounts.spotify.com/authorize?` +
  `client_id=${CLIENT_ID}` +
  `&response_type=code` +
  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
  `&code_challenge=${pkce.challenge}` +
  `&code_challenge_method=S256` +
  `&scope=${encodeURIComponent(SCOPE)}`;

// Open the URL
auth.openAuthUrl(authUrl, REDIRECT_URI);

// After callback, exchange manually using http.post
const response = http.post("https://accounts.spotify.com/api/token",
  `grant_type=authorization_code` +
  `&client_id=${CLIENT_ID}` +
  `&code=${authCode}` +
  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
  `&code_verifier=${pkce.verifier}`,
  { "Content-Type": "application/x-www-form-urlencoded" }
);

PKCE API Reference

FunctionDescription
auth.generatePKCE(length?)Generate PKCE verifier/challenge pair (stored automatically)
auth.getPKCE()Get stored PKCE pair
auth.startOAuthWithPKCE(config)High-level: generate PKCE + open OAuth URL
auth.exchangeCodeWithPKCE(config)High-level: exchange code using stored verifier

startOAuthWithPKCE config:

javascript
{
  authUrl: string,      // Required: OAuth authorization endpoint
  clientId: string,     // Required: Your OAuth client ID
  redirectUri: string,  // Required: Callback URL
  scope: string,        // Optional: OAuth scopes
  extraParams: object   // Optional: Additional URL parameters
}

exchangeCodeWithPKCE config:

javascript
{
  tokenUrl: string,     // Required: OAuth token endpoint
  clientId: string,     // Required: Your OAuth client ID
  code: string,         // Required: Authorization code from callback
  redirectUri: string,  // Optional: Must match authorization request
  extraParams: object   // Optional: Additional form parameters
}

Crypto Utilities

javascript
// Encrypt string with key
const encrypted = utils.encrypt("data", "key");
// { success: true, data: "base64-encrypted" }

// Decrypt string
const decrypted = utils.decrypt(encrypted.data, "key");
// { success: true, data: "data" }

// Generate random key
const key = utils.generateKey(32);
// { success: true, key: "base64", hex: "hex" }

FFmpeg API (Post-Processing)

javascript
// Execute raw FFmpeg command
const result = ffmpeg.execute('-i "input.flac" -c:a libmp3lame -b:a 320k "output.mp3"');
// { success: true, output: "..." } or { success: false, error: "..." }

// Get audio file info
const info = ffmpeg.getInfo("file.flac");
// { success: true, bit_depth: 16, sample_rate: 44100, duration: 180.5 }

// Convert with options (helper function)
const result = ffmpeg.convert("input.flac", "output.mp3", {
  codec: "libmp3lame",    // Audio codec
  bitrate: "320k",        // Bitrate
  sample_rate: 44100,     // Sample rate
  channels: 2             // Number of channels
});

Track Matching API

javascript
// Compare two strings with fuzzy matching (returns 0-1 similarity)
const similarity = matching.compareStrings("Track Name", "track name (remastered)");
// 0.85

// Compare durations with tolerance (in milliseconds)
const match = matching.compareDuration(180000, 182000, 3000);
// true (within 3 second tolerance)

// Normalize string for comparison (removes suffixes, special chars)
const normalized = matching.normalizeString("Track Name (Remastered) [Explicit]");
// "track name"

Extension Examples

Example 1: Simple Metadata Provider

Extension that provides search from a public API.

manifest.json:

json
{
  "name": "free-music-api",
  "displayName": "Free Music API",
  "version": "1.0.0",
  "description": "Search from Free Music API",
  "author": "Developer",
  "permissions": {
    "network": ["api.freemusic.com"]
  },
  "type": ["metadata_provider"],
  "settings": []
}

main.js:

javascript
let settings = {};

function initialize(config) {
  settings = config || {};
  log.info("Free Music API initialized");
  return true;
}

function cleanup() {
  log.info("Cleanup");
}

function searchTracks(query, limit) {
  const url = "https://api.freemusic.com/search?q=" + encodeURIComponent(query) + "&limit=" + limit;
  const response = http.get(url, {});

  if (!response.ok) return [];

  const data = JSON.parse(response.body);
  return data.results.map((t) => ({
    id: t.id,
    name: t.title,
    artists: t.artist,
    album_name: t.album,
    isrc: t.isrc,
    duration_ms: t.duration_ms,
    images: t.artwork,
  }));
}

// REQUIRED: Register the extension
registerExtension({
  initialize: initialize,
  cleanup: cleanup,
  searchTracks: searchTracks
});

Example 2: Download Provider with Auth

Extension that provides downloads with authentication.

manifest.json:

json
{
  "name": "premium-music",
  "displayName": "Premium Music",
  "version": "1.0.0",
  "description": "Download from Premium Music service",
  "author": "Developer",
  "permissions": {
    "network": [
      "api.premiummusic.com",
      "cdn.premiummusic.com"
    ],
    "storage": true
  },
  "type": ["download_provider"],
  "settings": [
    {
      "key": "email",
      "label": "Email",
      "type": "string",
      "required": true
    },
    {
      "key": "password",
      "label": "Password",
      "type": "string",
      "required": true
    }
  ]
}

main.js:

javascript
let settings = {};
let accessToken = null;

function initialize(config) {
  settings = config || {};

  if (!settings.email || !settings.password) {
    throw new Error("Email and password required");
  }

  // Login and save token
  const body = JSON.stringify({
    email: settings.email,
    password: settings.password
  });

  const response = http.post("https://api.premiummusic.com/auth/login", body, {
    "Content-Type": "application/json"
  });

  if (!response.ok) {
    throw new Error("Login failed");
  }

  const data = JSON.parse(response.body);
  accessToken = data.access_token;

  // Save token for next session
  storage.set("access_token", accessToken);

  log.info("Logged in successfully");
  return true;
}

function cleanup() {
  accessToken = null;
}

function checkAvailability(isrc, trackName, artistName) {
  const url = "https://api.premiummusic.com/search?isrc=" + isrc;
  const response = http.get(url, {
    Authorization: "Bearer " + accessToken
  });

  if (!response.ok) {
    return { available: false };
  }

  const data = JSON.parse(response.body);
  if (data.tracks && data.tracks.length > 0) {
    return {
      available: true,
      track_id: data.tracks[0].id,
      quality: "LOSSLESS",
    };
  }

  return { available: false };
}

function getDownloadUrl(trackId, quality) {
  const url = "https://api.premiummusic.com/tracks/" + trackId + "/download?quality=" + quality;
  const response = http.get(url, {
    Authorization: "Bearer " + accessToken
  });

  if (!response.ok) {
    return { success: false, error: "Failed to get URL" };
  }

  const data = JSON.parse(response.body);
  return {
    success: true,
    url: data.url,
    format: "flac",
    bit_depth: 24,
    sample_rate: 96000,
  };
}

function download(trackId, quality, outputPath, progressCallback) {
  // 1. Get download URL directly (availability checked by app)
  const downloadInfo = getDownloadUrl(trackId, quality);

  if (!downloadInfo.success) {
    return {
      success: false,
      error: downloadInfo.error,
      error_type: "stream_error",
    };
  }

  // 2. Download to outputPath provided by app
  const result = file.download(downloadInfo.url, outputPath, {
    headers: { Authorization: "Bearer " + accessToken },
    onProgress: function(received, total) {
      const percent = total > 0 ? (received / total) * 100 : 0;
      progressCallback(percent);
    }
  });

  if (!result.success) {
    return {
      success: false,
      error: result.error,
      error_type: "download_error",
    };
  }

  return {
    success: true,
    file_path: outputPath,
    format: "flac",
    actual_bit_depth: downloadInfo.bit_depth,
    actual_sample_rate: downloadInfo.sample_rate,
  };
}

// REQUIRED: Register the extension
registerExtension({
  initialize: initialize,
  cleanup: cleanup,
  checkAvailability: checkAvailability,
  getDownloadUrl: getDownloadUrl,
  download: download
});

console.log("Premium Music extension loaded!");

Packaging & Distribution

Creating Extension File

  1. Create a folder with manifest.json and main.js files
  2. ZIP the folder
  3. Rename .zip to .spotiflac-ext

Using Command Line:

bash
# Windows (PowerShell)
Compress-Archive -Path manifest.json, main.js -DestinationPath my-extension.zip
Rename-Item my-extension.zip my-extension.spotiflac-ext

# Linux/Mac
zip my-extension.zip manifest.json main.js
mv my-extension.zip my-extension.spotiflac-ext

Installing Extension

  1. Open SpotiFLAC
  2. Go to Settings → Extensions
  3. Tap the "+" or "Import" button
  4. Select the .spotiflac-ext file
  5. Extension will be loaded and appear in the list

Upgrading Extension

SpotiFLAC supports upgrading extensions without losing data:

  1. Create a new version of your extension with a higher version number in manifest.json
  2. Package the extension as usual
  3. Install the new .spotiflac-ext file
  4. SpotiFLAC will automatically detect it's an upgrade and:
    • Preserve the extension's data directory (settings, cached data)
    • Replace the extension code with the new version
    • Reload the extension

Important Notes:

  • Upgrades only: You can only upgrade to a higher version. Downgrading is not allowed.
  • Same version: Installing the same version will show an error "Extension is already installed"
  • Data preservation: User settings and stored data are preserved during upgrades

Version Format: Use semantic versioning (x.y.z):

  • 1.0.01.0.1 (patch upgrade) ✓
  • 1.0.01.1.0 (minor upgrade) ✓
  • 1.0.02.0.0 (major upgrade) ✓
  • 1.1.01.0.0 (downgrade) ✗

Troubleshooting

Error: "extension did not call registerExtension()"

The extension script did not call registerExtension() function.

Solution: Add registerExtension({...}) at the end of your script with all your provider functions. See the Main Script section for examples.

Error: "Permission denied for domain X" / "network access denied"

Extension is trying to access a domain not in permissions.network.

Solution: Add the domain to the permissions.network array in manifest. For services with multiple domains (like YouTube), you need to add ALL domains:

json
"permissions": {
  "network": [
    "music.youtube.com",
    "www.youtube.com", 
    "youtube.com",
    "youtubei.googleapis.com",
    "*.googlevideo.com",
    "*.youtube.com",
    "*.ytimg.com"
  ]
}

Common domains for popular services:

  • YouTube Music: youtubei.googleapis.com, music.youtube.com, *.youtube.com, *.googlevideo.com, *.ytimg.com
  • SoundCloud: api.soundcloud.com, api-v2.soundcloud.com, *.sndcdn.com
  • Bandcamp: bandcamp.com, *.bandcamp.com, *.bcbits.com

Error: "POST body is [object Object]"

The HTTP POST body is being converted to string incorrectly.

Solution: As of v3.0.0-alpha.2, http.post() now automatically stringifies objects to JSON. If you're on an older version, manually stringify:

javascript
// Old way (still works)
http.post(url, JSON.stringify(body), headers);

// New way (v3.0.0-alpha.2+)
http.post(url, body, headers);  // Objects auto-stringified

Error: "Function X is not defined"

SpotiFLAC cannot find the required function.

Solution: Make sure initialize and cleanup functions exist. If type includes metadata_provider, ensure searchTracks exists. If type includes download_provider, ensure checkAvailability, getDownloadUrl, and download exist.

Error: "Invalid manifest"

The manifest.json format is invalid.

Solution:

  • Ensure JSON is valid (use a JSON validator)
  • Ensure all required fields exist
  • Ensure name is lowercase without spaces

Extension doesn't appear after install

Solution:

  • Ensure file is a valid ZIP
  • Ensure manifest.json exists at ZIP root
  • Check logs for error messages

HTTP request fails

Solution:

  • Ensure domain is in permissions.network
  • Check URL and parameters
  • Check response status and body for error messages
  • Use log.debug() for debugging
  • Check response.ok property (true if status 2xx)

Download fails

Solution:

  • Ensure storage permission is in manifest
  • Ensure URL is valid and accessible
  • Check if server requires auth headers

Error: "file access denied: extension does not have 'file' permission"

Extension is trying to use file operations without the file permission.

Solution: Add "file": true to your permissions in manifest.json:

json
"permissions": {
  "network": ["api.example.com"],
  "storage": true,
  "file": true
}

Error: "file access denied: absolute paths are not allowed"

Extension is trying to access a file using an absolute path (e.g., /sdcard/Music/file.flac or C:\Music\file.flac).

Solution: Use relative paths within the extension's sandbox directory. For download operations, the app will automatically provide the correct output path. Example:

javascript
// ❌ Wrong - absolute path
file.write("/sdcard/Music/song.flac", data);

// ✅ Correct - relative path (within extension sandbox)
file.write("cache/temp.flac", data);

// ✅ Correct - use outputPath provided by download function
function download(trackId, quality, outputPath, progressCallback) {
  // outputPath is already the correct absolute path managed by the app
  return file.download(streamUrl, outputPath, { headers: headers });
}

Error: "file access denied: path 'X' is outside sandbox"

Extension is trying to access a file outside its sandbox using path traversal (e.g., ../../../etc/passwd).

Solution: Only use paths within your extension's data directory. Path traversal attempts are blocked for security.

Error: "Cannot downgrade extension"

You're trying to install an older version of an already installed extension.

Solution: SpotiFLAC only allows upgrades (higher version numbers). If you need to downgrade:

  1. Uninstall the current extension first
  2. Then install the older version

Error: "Extension is already installed"

You're trying to install the same version that's already installed.

Solution: Bump the version number in manifest.json if you've made changes.

Error: "timeout: extension took too long to respond"

Extension function exceeded the execution time limit.

Solution:

  • Default timeout is 30 seconds for most operations
  • Download operations have 5 minute timeout
  • Post-processing has 2 minute timeout
  • Optimize your code to avoid infinite loops or long-running operations
  • For downloads, ensure you're streaming data rather than loading everything into memory

Thumbnails not showing correctly in search results

Custom search results may show square thumbnails instead of the expected aspect ratio.

Solution:

  1. Add thumbnailRatio to your searchBehavior in manifest:
    json
    "searchBehavior": {
      "enabled": true,
      "thumbnailRatio": "wide"  // For 16:9 YouTube-style thumbnails
    }
    
  2. Reinstall/upgrade the extension after changing the manifest
  3. Make sure your customSearch function returns images field with valid URLs

Technical Details & Behavior

This section clarifies implementation details that may not be obvious from the API reference.

Token Refresh Handling

SpotiFLAC does NOT automatically refresh tokens. Extensions must handle token refresh manually.

Recommended Pattern:

javascript
function ensureValidToken() {
  const tokens = auth.getTokens();
  
  // Check if token exists and is not expired
  if (tokens.access_token && !tokens.is_expired) {
    return true;
  }
  
  // Token expired or missing - try to refresh
  const refreshToken = credentials.get("refresh_token");
  if (!refreshToken) {
    return false; // Need full re-authentication
  }
  
  // Call your OAuth provider's refresh endpoint
  const response = http.post("https://api.example.com/oauth/token", {
    grant_type: "refresh_token",
    refresh_token: refreshToken,
    client_id: settings.client_id
  }, { "Content-Type": "application/json" });
  
  if (!response.ok) {
    auth.clearAuth();
    return false;
  }
  
  const newTokens = JSON.parse(response.body);
  
  // Update stored tokens
  credentials.store("access_token", newTokens.access_token);
  if (newTokens.refresh_token) {
    credentials.store("refresh_token", newTokens.refresh_token);
  }
  
  // Update auth state
  auth.setAuthCode({
    access_token: newTokens.access_token,
    refresh_token: newTokens.refresh_token || refreshToken,
    expires_in: newTokens.expires_in
  });
  
  return true;
}

// Use before any authenticated API call
function makeAuthenticatedRequest(url) {
  if (!ensureValidToken()) {
    return { error: "Authentication required" };
  }
  
  const tokens = auth.getTokens();
  return http.get(url, {
    "Authorization": "Bearer " + tokens.access_token
  });
}

Key Points:

  • auth.getTokens().is_expired returns true if current time > expires_at
  • You must implement refresh logic yourself
  • Store refresh tokens using credentials.store() for persistence
  • Call auth.setAuthCode() after refresh to update the auth state

Storage Limits

Storage TypeLimitNotes
storage APIUnlimitedStored as JSON in extension's data directory
credentials APIUnlimitedEncrypted with AES-GCM, stored in .credentials.enc
File APIUnlimitedLimited to extension's sandbox directory

Storage Location:

  • Android: /data/data/com.zarz.spotiflac/files/extensions/{extension-id}/
  • Each extension has isolated storage (cannot access other extensions' data)

Best Practices:

  • Don't store large binary data in storage - use File API instead
  • Clean up unused data in cleanup() function
  • Use credentials for sensitive data (API keys, tokens, passwords)

File API Path Resolution

All File API paths are relative to the extension's data directory unless an absolute path is provided.

javascript
// Relative paths (recommended)
file.write("cache/data.json", data);     // → {ext_dir}/cache/data.json
file.read("config.txt");                  // → {ext_dir}/config.txt
file.exists("downloads/track.flac");      // → {ext_dir}/downloads/track.flac

// Absolute paths (allowed for download queue integration)
file.write("/storage/emulated/0/Music/track.flac", data);  // Allowed
file.read("/sdcard/Download/file.txt");                     // Allowed

Security:

  • Relative paths are sandboxed to extension's data directory
  • Attempting to escape sandbox (e.g., ../other-extension/) will fail
  • Absolute paths are allowed for download queue integration (app controls these paths)

Extension Data Directory Structure:

text
{extension-id}/
├── storage.json          # storage API data
├── .credentials.enc      # encrypted credentials
├── cache/                # your cache files
└── downloads/            # your download files

HTTP Redirect Handling

HTTP redirects are handled automatically by the HTTP client (follows redirects by default).

javascript
// Redirects are followed automatically
const response = http.get("https://example.com/redirect");
// response.url will be the final URL after redirects
// response.statusCode will be the final response status

// The HTTP client follows up to 10 redirects by default
// If more redirects occur, the request will fail

Behavior:

  • 301, 302, 303, 307, 308 redirects are followed automatically
  • Cookies are preserved across redirects (same domain)
  • Maximum 10 redirects (Go's http.Client default)
  • Final response is returned (not intermediate redirects)

If you need to prevent redirects (rare), you can check the response and handle manually:

javascript
// Most cases: just use the response directly
const response = http.get(url);
if (response.ok) {
  // Final response after any redirects
}

Standard Error Types

Use these standard error_type values in download results for consistent error handling:

error_typeDescriptionUser Action
not_foundTrack not available on this serviceTry another service
auth_errorAuthentication failed or expiredRe-authenticate
rate_limitToo many requests, rate limitedWait and retry
geo_blockedContent not available in user's regionUse VPN or try another service
stream_errorFailed to get stream URLRetry or try another quality
download_errorFile download failedCheck network and retry
format_errorUnsupported or invalid formatTry another quality
quota_exceededUser's download quota exceededWait for quota reset
premium_requiredPremium subscription requiredUpgrade account
server_errorService temporarily unavailableRetry later

Example Usage:

javascript
function download(request, progressCallback) {
  // Check availability
  const availability = checkAvailability(request.isrc, request.track_name, request.artist_name);
  
  if (!availability.available) {
    return {
      success: false,
      error: "Track not found on this service",
      error_type: "not_found"
    };
  }
  
  // Check authentication
  if (!auth.isAuthenticated()) {
    return {
      success: false,
      error: "Please authenticate first",
      error_type: "auth_error"
    };
  }
  
  // Get stream URL
  const streamInfo = getDownloadUrl(availability.track_id, request.quality);
  
  if (!streamInfo.success) {
    // Determine error type from response
    if (streamInfo.status === 429) {
      return {
        success: false,
        error: "Rate limited, please wait",
        error_type: "rate_limit"
      };
    }
    if (streamInfo.status === 451 || streamInfo.status === 403) {
      return {
        success: false,
        error: "Content not available in your region",
        error_type: "geo_blocked"
      };
    }
    return {
      success: false,
      error: streamInfo.error || "Failed to get stream",
      error_type: "stream_error"
    };
  }
  
  // Download file
  const result = file.download(streamInfo.url, request.output_path, {
    headers: { "Authorization": "Bearer " + auth.getTokens().access_token },
    onProgress: progressCallback
  });
  
  if (!result.success) {
    return {
      success: false,
      error: "Download failed: " + result.error,
      error_type: "download_error"
    };
  }
  
  return {
    success: true,
    file_path: request.output_path,
    format: streamInfo.format
  };
}

HTTP Timeout

The HTTP client has a 30 second timeout for all requests.

javascript
// Requests that take longer than 30 seconds will fail
const response = http.get("https://slow-api.example.com/data");
if (response.error) {
  // Could be timeout: "context deadline exceeded" or similar
  log.error("Request failed:", response.error);
}

For large file downloads, use file.download() which has a longer timeout and supports progress callbacks.


Tips & Best Practices

  1. Always handle errors - Wrap HTTP calls in try-catch
  2. Use logging - log.debug() is very helpful for debugging
  3. Validate settings - Check required settings in initialize()
  4. Cache tokens - Use storage to save auth tokens
  5. Respect rate limits - Don't spam APIs
  6. Test thoroughly - Test with various inputs before distribution
  7. List all domains - For complex APIs (YouTube, etc.), list ALL required domains in permissions

Authentication API

SpotiFLAC provides a built-in authentication system for extensions that need OAuth or other auth flows (e.g., Apple Music, Spotify Premium, etc.).

Auth API Reference

javascript
// Request the app to open an OAuth URL
// The app will open this URL in a browser and wait for callback
auth.openAuthUrl(authUrl, callbackUrl);

// Get the auth code (set by app after OAuth callback)
const code = auth.getAuthCode();

// Set auth tokens after exchanging code for tokens
auth.setAuthCode({
  code: "auth_code",
  access_token: "access_token",
  refresh_token: "refresh_token",
  expires_in: 3600  // seconds
});

// Check if extension is authenticated
const isAuth = auth.isAuthenticated();

// Get current tokens
const tokens = auth.getTokens();
// Returns: { access_token, refresh_token, is_authenticated, expires_at, is_expired }

// Clear all auth state (logout)
auth.clearAuth();

Credentials API (Encrypted Storage)

For storing sensitive data like API keys, passwords, or tokens, use the encrypted credentials API:

javascript
// Store a credential (encrypted on disk)
credentials.store("api_key", "my-secret-key");
credentials.store("user_data", { email: "user@example.com", token: "..." });

// Get a credential
const apiKey = credentials.get("api_key");
const userData = credentials.get("user_data");

// Check if credential exists
const hasKey = credentials.has("api_key");

// Remove a credential
credentials.remove("api_key");

Crypto Utilities

For custom encryption needs:

javascript
// Encrypt data with a key
const result = utils.encrypt("sensitive data", "encryption-key");
// Returns: { success: true, data: "base64-encrypted-string" }

// Decrypt data
const decrypted = utils.decrypt(result.data, "encryption-key");
// Returns: { success: true, data: "sensitive data" }

// Generate a random encryption key
const key = utils.generateKey(32);  // 32 bytes = 256 bits
// Returns: { success: true, key: "base64-key", hex: "hex-key" }

PKCE (Proof Key for Code Exchange) is the recommended OAuth flow for mobile/native apps. SpotiFLAC provides built-in PKCE support for secure OAuth authentication without exposing client secrets.

Complete OAuth Example with PKCE

javascript
let settings = {};
const CLIENT_ID = "your_spotify_client_id";
const REDIRECT_URI = "spotiflac://spotify-callback";
const SCOPES = "user-read-private user-library-read playlist-read-private";

function initialize(config) {
  settings = config || {};
  
  // Check if already authenticated
  if (auth.isAuthenticated()) {
    const tokens = auth.getTokens();
    if (!tokens.is_expired) {
      log.info("Already authenticated");
      return true;
    }
    // Token expired, need to refresh or re-auth
    log.info("Token expired, need to re-authenticate");
  }
  
  return true;
}

function startLogin() {
  const result = auth.startOAuthWithPKCE({
    authUrl: "https://accounts.spotify.com/authorize",
    clientId: CLIENT_ID,
    redirectUri: REDIRECT_URI,
    scope: SCOPES
  });
  
  if (!result.success) {
    log.error("Failed to start OAuth:", result.error);
    return false;
  }
  
  log.info("Please authorize in the browser...");
  return true;
}

function handleCallback() {
  const code = auth.getAuthCode();
  if (!code) {
    log.error("No auth code received");
    return false;
  }
  
  const tokens = auth.exchangeCodeWithPKCE({
    tokenUrl: "https://accounts.spotify.com/api/token",
    clientId: CLIENT_ID,
    redirectUri: REDIRECT_URI,
    code: code
  });
  
  if (!tokens.success) {
    log.error("Token exchange failed:", tokens.error);
    return false;
  }
  
  log.info("Authentication successful!");
  // Tokens are now stored and accessible via auth.getTokens()
  return true;
}

function makeAuthenticatedRequest(url) {
  const tokens = auth.getTokens();
  if (!tokens.access_token) {
    throw new Error("Not authenticated");
  }
  
  return http.get(url, {
    "Authorization": "Bearer " + tokens.access_token
  });
}

// Register extension
registerExtension({
  initialize: initialize,
  startLogin: startLogin,
  handleCallback: handleCallback,
  // ... other functions
});

OAuth Flow Example (Traditional)

Here's a complete example of implementing OAuth authentication without PKCE:

javascript
let settings = {};
let accessToken = null;

function initialize(config) {
  settings = config || {};
  
  // Check if we have stored tokens
  const storedToken = credentials.get("access_token");
  if (storedToken) {
    accessToken = storedToken;
    // Verify token is still valid
    if (auth.isAuthenticated()) {
      log.info("Using stored authentication");
      return true;
    }
  }
  
  // Need to authenticate
  log.info("Authentication required");
  return true;
}

// Call this to start OAuth flow
function startAuth() {
  const clientId = settings.client_id;
  const redirectUri = "spotiflac://oauth/callback";
  
  const authUrl = `https://api.example.com/oauth/authorize?` +
    `client_id=${clientId}&` +
    `redirect_uri=${encodeURIComponent(redirectUri)}&` +
    `response_type=code&` +
    `scope=read,download`;
  
  // Request app to open auth URL
  auth.openAuthUrl(authUrl, redirectUri);
  
  return { success: true, message: "Please complete authentication in browser" };
}

// Call this after user completes OAuth (app will set the code)
function completeAuth() {
  const code = auth.getAuthCode();
  if (!code) {
    return { success: false, error: "No auth code received" };
  }
  
  // Exchange code for tokens
  const response = http.post("https://api.example.com/oauth/token", JSON.stringify({
    grant_type: "authorization_code",
    code: code,
    client_id: settings.client_id,
    client_secret: settings.client_secret
  }), {
    "Content-Type": "application/json"
  });
  
  if (response.statusCode !== 200) {
    return { success: false, error: "Token exchange failed" };
  }
  
  const tokens = JSON.parse(response.body);
  
  // Store tokens securely
  credentials.store("access_token", tokens.access_token);
  credentials.store("refresh_token", tokens.refresh_token);
  
  // Update auth state
  auth.setAuthCode({
    access_token: tokens.access_token,
    refresh_token: tokens.refresh_token,
    expires_in: tokens.expires_in
  });
  
  accessToken = tokens.access_token;
  
  return { success: true };
}

// Use in download function
function download(trackId, quality, outputPath, progressCallback) {
  if (!auth.isAuthenticated()) {
    return { success: false, error: "Not authenticated", error_type: "auth_error" };
  }
  
  const tokens = auth.getTokens();
  if (tokens.is_expired) {
    // Refresh token
    const refreshed = refreshAccessToken();
    if (!refreshed.success) {
      return { success: false, error: "Token refresh failed", error_type: "auth_error" };
    }
  }
  
  // Use accessToken for API calls
  const response = http.get(`https://api.example.com/tracks/${trackId}/stream`, {
    "Authorization": "Bearer " + accessToken
  });
  
  // ... rest of download logic
}

function refreshAccessToken() {
  const refreshToken = credentials.get("refresh_token");
  if (!refreshToken) {
    return { success: false };
  }
  
  const response = http.post("https://api.example.com/oauth/token", JSON.stringify({
    grant_type: "refresh_token",
    refresh_token: refreshToken,
    client_id: settings.client_id
  }), {
    "Content-Type": "application/json"
  });
  
  if (response.statusCode !== 200) {
    return { success: false };
  }
  
  const tokens = JSON.parse(response.body);
  credentials.store("access_token", tokens.access_token);
  accessToken = tokens.access_token;
  
  auth.setAuthCode({
    access_token: tokens.access_token,
    expires_in: tokens.expires_in
  });
  
  return { success: true };
}

// Register extension
registerExtension({
  initialize: initialize,
  cleanup: function() { accessToken = null; },
  startAuth: startAuth,
  completeAuth: completeAuth,
  download: download
});

Data Schema Reference

Track Object

javascript
{
  id: "track123",           // Unique ID (required)
  name: "Track Name",       // Track title (required)
  artists: "Artist Name",   // Artist(s) (required)
  album_name: "Album",      // Album name (optional)
  album_artist: "Artist",   // Album artist (optional)
  isrc: "USRC12345678",     // ISRC code (optional but recommended for matching)
  duration_ms: 240000,      // Duration in milliseconds (required)
  track_number: 1,          // Track number (optional)
  disc_number: 1,           // Disc number (optional)
  release_date: "2024-01-01", // Release date (optional)
  images: "https://..."     // Cover art/thumbnail URL (optional)
}

Note on images field:

  • For custom search results, this URL will be displayed as the track thumbnail
  • The aspect ratio is controlled by searchBehavior.thumbnailRatio in your manifest
  • Use high-quality URLs for best display (recommended: 300x300 for square, 480x270 for wide)

Album Object

javascript
{
  id: "album123",
  name: "Album Name",
  artists: "Artist Name",
  release_date: "2024-01-01",
  total_tracks: 12,
  images: "https://...",
  album_type: "album",      // "album", "single", "compilation"
  tracks: [/* array of Track objects */]
}

Artist Object

javascript
{
  id: "artist123",
  name: "Artist Name",
  images: "https://...",
  albums: [/* array of Album objects */]
}

Download Result Object

javascript
// Success
{
  success: true,
  file_path: "/path/to/file.flac",
  format: "flac",
  actual_bit_depth: 24,
  actual_sample_rate: 96000,
  // Optional metadata (used when skipMetadataEnrichment is true)
  title: "Track Name",
  artist: "Artist Name",
  album: "Album Name",
  album_artist: "Album Artist",
  track_number: 1,
  disc_number: 1,
  release_date: "2024-01-01",
  cover_url: "https://...",
  isrc: "USRC12345678"
}

// Error
{
  success: false,
  error: "Error message",
  error_type: "not_found" | "stream_error" | "download_error" | "auth_error"
}

Skip Metadata Enrichment

When skipMetadataEnrichment is set to true in the manifest, SpotiFLAC will use the metadata returned by the extension's download() function instead of enriching from Deezer/Spotify. This is useful for:

  • YouTube downloads: The source already has metadata, no need to search Deezer/Spotify
  • Direct source downloads: When the extension provides complete metadata from its source
  • Performance: Skip unnecessary API calls to metadata providers

To use this feature:

  1. Set "skipMetadataEnrichment": true in your manifest.json
  2. Return metadata fields in your download() function result:
javascript
function download(trackId, quality, outputPath, progressCallback) {
  // ... download logic ...
  
  return {
    success: true,
    file_path: outputPath,
    // Include metadata from your source
    title: videoInfo.title,
    artist: videoInfo.artist,
    album: videoInfo.album || videoInfo.title,
    cover_url: videoInfo.thumbnail
  };
}

Changelog

  • v1.3 - Added Technical Details section (token refresh, storage limits, file paths, redirects, error types, HTTP timeout), PKCE OAuth support with complete Spotify example, HMAC-SHA256 utilities, form-encoded POST documentation
  • v1.2 - Added thumbnail ratio customization (thumbnailRatio, thumbnailWidth, thumbnailHeight)
  • v1.1 - Added extension upgrade support (no downgrade), improved documentation
  • v1.0 - Initial release

Support

If you have questions or issues:

  1. Open an issue on the GitHub repository
  2. Include error logs and reproduction steps
  3. Include SpotiFLAC and extension versions

Happy coding!