Skip to content

Instantly share code, notes, and snippets.

@HemanthJabalpuri
Last active September 19, 2026 12:52
Show Gist options
  • Select an option

  • Save HemanthJabalpuri/79a05fbbdee5be2a1760977b5f18ea95 to your computer and use it in GitHub Desktop.

Select an option

Save HemanthJabalpuri/79a05fbbdee5be2a1760977b5f18ea95 to your computer and use it in GitHub Desktop.
JioSaavn plugin for MusicFree app by maotoumao
// Init
const LOG_LEVELS = { none: 0, error: 1, warn: 2, info: 3, debug: 4 };
function getUserVars() {
try {
return (env && typeof env.getUserVariables === 'function') ? env.getUserVariables() : {};
} catch (_) {
return {};
}
}
function log(level, ...args) {
const vars = getUserVars();
const logLevel = vars.logLevel || 'error';
const currentLevel = LOG_LEVELS[logLevel] !== undefined ? LOG_LEVELS[logLevel] : LOG_LEVELS.error;
const msgLevel = LOG_LEVELS[level] || 0;
if (msgLevel <= currentLevel) {
const prefix = '[JioSaavn]';
const method = level === 'error' ? console.error :
level === 'warn' ? console.warn :
console.log;
method(prefix, ...args);
}
}
// get language settings
// not working for now
function getLanguageHeader() {
try {
const vars = getUserVars();
const languages = vars.languages || '';
log('debug', '[getLanguageHeader] Languages are...', languages);
if (languages) {
// en-US,en; geo=17.3850,78.4867,IN,Telangana,Hyderabad,500001; mm_latlong=17.3850,78.4867; DL=english;
return { 'Cookie': `L=${languages}` };
}
} catch (_) {}
return {};
}
const SEARCH_LIMIT = 10;
const RADIO_DEFAULT_COUNT = 20;
async function callAPI(call, extraParams = {}, extraHeaders = {}) {
log('debug', '[callAPI] Calling', call, 'with extraParams:', extraParams, 'with extraHeaders:', extraHeaders);
const params = {
__call: call,
_format: 'json',
_marker: 0,
api_version: 4,
ctx: 'web6dot0',
...extraParams
};
const qs = new URLSearchParams(params).toString();
const url = `https://www.jiosaavn.com/api.php?${qs}`;
const res = await fetch(url, { headers: extraHeaders });
log('debug', '[callAPI] Response status:', res.status);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function searchSongsAPI(q, p, n) {
log('debug', '[searchSongsAPI] query:', q, 'page:', p, 'limit:', n);
return callAPI('search.getResults', { q, p, n });
}
function searchSheetsAPI(type, q, p, n) {
log('debug', '[searchSheetsAPI] query:', q, 'page:', p, 'limit:', n);
return callAPI(type === 'album' ? 'search.getAlbumResults' : 'search.getPlaylistResults', { q, p, n });
}
function searchArtistsAPI(q, p, n) {
log('debug', '[searchArtistsAPI] query:', q, 'page:', p, 'limit:', n);
return callAPI('search.getArtistResults', { q, p, n });
}
function getSongAPI(token) {
log('debug', '[getSongAPI] token:', token);
return callAPI('webapi.get', {
token,
type: 'song',
includeMetaTags: 0
});
}
function getLyricsAPI(token) {
log('debug', '[getLyricsAPI] token:', token);
return callAPI('webapi.get', {
token,
type: 'lyrics'
});
}
function getSheetAPI(type, token, p, n) {
log('debug', '[getSheetAPI] type:', type, 'token:', token);
const params = {
token,
type,
includeMetaTags: 0
};
if (type === 'playlist') {
params.p = p;
params.n = n;
}
return callAPI('webapi.get', params);
}
function getArtistSongsAPI(artistId, page, category) {
// Artist pagination starts from 0, so convert 1‑based page to 0‑based
const zeroBasedPage = Math.max(0, (page || 1) - 1);
log('debug', '[getArtistSongsAPI] artistId:', artistId, 'page:', page, 'zeroBasedPage:', zeroBasedPage);
return callAPI('artist.getArtistMoreSong', {
artistId,
page: zeroBasedPage,
category: category || 'popular',
sort_order: category === 'latest' ? 'desc' : 'asc'
});
}
function getArtistAlbumsAPI(artistId, page, category) {
const zeroBasedPage = Math.max(0, (page || 1) - 1);
log('debug', '[getArtistAlbumsAPI] artistId:', artistId, 'page:', page, 'zeroBasedPage:', zeroBasedPage);
return callAPI('artist.getArtistMoreAlbum', {
artistId,
page: zeroBasedPage,
category: category || 'popular',
sort_order: category === 'latest' ? 'desc' : 'asc'
});
}
function createRadioStationAPI(stationType, params) {
const apiCall = stationType === 'featured' ? 'webradio.createFeaturedStation' : 'webradio.createArtistStation';
const payload = { language: params.language || 'hindi', pid: '', name: params.name, mode: '', artistid: '' };
if (stationType === 'artist') {
payload.query = params.query || '';
} else {
payload.query = '';
}
log('debug', '[createRadioStationAPI] Calling', apiCall, 'with payload:', payload);
return callAPI(apiCall, payload, getLanguageHeader());
}
function getRadioSongsAPI(stationId, count, page) {
log('debug', '[getRadioSongsAPI] Fetching songs for station:', stationId, 'page:', page);
// TODO: we are not using page var, let's think about it later
return callAPI('webradio.getSong', {
stationid: stationId,
k: count || RADIO_DEFAULT_COUNT,
next: 1
}, getLanguageHeader());
}
const CryptoJs = require("crypto-js");
const DESKEY = CryptoJs.enc.Utf8.parse('38346591');
const DES_OPTIONS = {
mode: CryptoJs.mode.ECB,
padding: CryptoJs.pad.Pkcs7
};
function decryptMediaUrl(encrypted) {
log('debug', '[decryptMediaUrl] decrypting length:', encrypted.length);
const decrypted = CryptoJs.DES.decrypt(encrypted, DESKEY, DES_OPTIONS);
log('debug', '[decryptMediaUrl] decrypted (first 50 chars):', decrypted.toString(CryptoJs.enc.Utf8).slice(0, 50));
return decrypted.toString(CryptoJs.enc.Utf8);
}
function decode(text) {
if (!text) return '';
let result = text;
let previous;
do {
previous = result;
result = result.replace(/&amp;/g, '&').replace(/&#039;/g, "'").replace(/&quot;/g, '"');
} while (result !== previous);
return result;
}
function extractTokenFromUrl(url) {
const parts = url.split('/');
return parts[parts.length - 1];
}
function formatSong(song) {
log('debug', '[formatSong] formatting:', song.title, 'id:', song.id);
const moreInfo = song.more_info || {};
const subtitle = decode(song.subtitle || '');
const artist = subtitle.includes(' - ') ? subtitle.split(' - ')[0] : subtitle;
return {
id: extractTokenFromUrl(song.perma_url),
title: decode(song.title),
artist: artist || 'Unknown Artist',
album: decode(moreInfo.album || ''),
artwork: (song.image || '').replace(/\d+x\d+\.jpg$/, '500x500.jpg'),
duration: parseInt(moreInfo.duration) || 0,
_encryptedUrl: moreInfo.encrypted_media_url,
_hasLyrics: moreInfo.has_lyrics === 'true' || moreInfo.has_lyrics === true
};
}
function formatSheet(sheet) {
const result = {
id: extractTokenFromUrl(sheet.perma_url),
title: decode(sheet.title),
artist: decode(sheet.subtitle || ''),
artwork: sheet.image || '',
type: sheet.type // custom key
};
// If it's a song or radio station, store the raw data for later use in getSheetInfo
if (result.type === 'song' || result.type === 'radio_station') {
result._raw = sheet;
}
return result;
}
function formatSheetTracks(data) {
const tracks = data.list || [];
log('debug', '[formatSheetTracks] tracks count:', tracks.length);
return tracks.map(formatSong);
}
function formatArtist(artist) {
return {
id: artist.id,
name: decode(artist.name),
avatar: (artist.image || '').replace(/\d+x\d+\.jpg$/, '150x150.jpg')
};
}
let urlCache = null; // will hold { id, url } used by getMediaSource
const QUALITY_MAP = { low: '48', standard: '96', high: '160', super: '320' };
function getMediaSource(musicItem, quality) {
log('debug', '[getMediaSource] song:', musicItem.title, 'quality:', quality);
if (urlCache === null || urlCache.id !== musicItem.id) {
log('debug', '[getMediaSource] cache miss – decrypting');
const decrypted = decryptMediaUrl(musicItem._encryptedUrl);
urlCache = { id: musicItem.id, url: decrypted };
} else {
log('debug', '[getMediaSource] cache hit');
}
const finalUrl = urlCache.url.replace(/_[0-9]+(?=\.)/, '_' + QUALITY_MAP[quality]);
log('debug', '[getMediaSource] final URL:', finalUrl);
return { url: finalUrl };
}
async function getLyric(musicItem) {
log('debug', '[getLyric] fetching lyrics for:', musicItem.title);
if (!musicItem._hasLyrics) {
log('debug', '[getLyric] no lyrics flag');
return { rawLrc: '' };
}
const response = await getLyricsAPI(musicItem.id);
const rawLyrics = response?.lyrics?.lyrics;
log('debug', '[getLyric] lyrics length:', rawLyrics?.length);
return { rawLrc: '[00:00.00] ' + rawLyrics.replace(/<br>/g, '\n') };
}
const radioStationCache = {
sheetId: null,
stationId: null
};
async function getRadioStationInfo(radioSheetItem, page) {
log('debug', '[getRadioStationInfo] Processing radio station:', radioSheetItem.title);
const moreInfo = radioSheetItem._raw.more_info || {};
const stationType = moreInfo.featured_station_type || 'featured';
const params = {
language: moreInfo.language || 'hindi',
name: encodeURIComponent(radioSheetItem.title),
query: encodeURIComponent(moreInfo.query) || ''
};
// Check if we have a cached station ID for this sheet
let stationId;
if (radioStationCache.sheetId === radioSheetItem.id && radioStationCache.stationId) {
stationId = radioStationCache.stationId;
log('debug', '[getRadioStationInfo] Using cached stationId:', stationId);
} else if (page === 1) {
// Create the station
const createResponse = await createRadioStationAPI(stationType, params);
stationId = createResponse?.stationid;
if (!stationId) {
log('warn', '[getRadioStationInfo] No stationid in response:', createResponse);
return { isEnd: true, musicList: [] };
}
// Update cache
radioStationCache.sheetId = radioSheetItem.id;
radioStationCache.stationId = stationId;
log('debug', '[getRadioStationInfo] Created and cached stationId:', stationId);
} else {
// Cache missing but page > 1 – log and return empty
log('warn', '[getRadioStationInfo] Cache missing for page', page, ', returning empty');
return { isEnd: true, musicList: [] };
}
// Fetch songs using the station ID
const songsResponse = await getRadioSongsAPI(encodeURIComponent(stationId), RADIO_DEFAULT_COUNT, page);
// Parse response: object with numeric keys, each containing { song: { ... } }
const songs = [];
for (const key of Object.keys(songsResponse)) {
const item = songsResponse[key];
if (item && item.song) {
songs.push(item.song);
}
}
const isEnd = songs.length < RADIO_DEFAULT_COUNT;
const musicList = songs.map(formatSong);
log('debug', '[getRadioStationInfo] Returning', musicList.length, 'songs, isEnd:', isEnd);
return { isEnd, musicList };
}
async function getSheetInfo(sheetItem, page) {
log('debug', '[getSheetInfo] type:', sheetItem.type, 'sheet:', sheetItem.title);
// --- Radio station ---
if (sheetItem.type === 'radio_station') {
return getRadioStationInfo(sheetItem, page);
}
// --- Song sheet (single track) ---
if (sheetItem.type === 'song') {
log('debug', '[getSheetInfo] Song sheet detected, formatting single track');
return {
isEnd: true,
musicList: [ formatSong(sheetItem._raw) ]
};
}
// --- Album / Playlist ---
const response = await getSheetAPI(sheetItem.type, sheetItem.id, page, 50);
const tracks = formatSheetTracks(response);
log('debug', '[getSheetInfo] tracks count:', tracks.length);
const isEnd = sheetItem.type === 'playlist' ? tracks.length < 50 : true;
return { isEnd, musicList: tracks };
}
async function getArtistWorks(artistItem, page, type) {
log('debug', '[getArtistWorks] artist:', artistItem.name, 'page:', page, 'type:', type);
// Handle albums
if (type === 'album') {
const response = await getArtistAlbumsAPI(artistItem.id, page);
const rawAlbums = response.topAlbums.albums || [];
const albums = rawAlbums.map(formatSheet);
log('debug', '[getArtistWorks] fetched', albums.length, 'albums');
const limit = 10; // adjust if API has different default
return {
isEnd: albums.length < limit,
data: albums
};
}
// Handle songs
const response = await getArtistSongsAPI(artistItem.id, page);
const rawSongs = response.topSongs.songs || [];
const songs = rawSongs.map(formatSong);
log('debug', '[getArtistWorks] fetched', songs.length, 'songs');
const limit = 10; // adjust if API has different default
return {
isEnd: songs.length < limit,
data: songs
};
}
const searchMap = {
music: {
call: (kw, p) => searchSongsAPI(kw, p, SEARCH_LIMIT),
formatter: formatSong
},
album: {
call: (kw, p) => searchSheetsAPI('album', kw, p, SEARCH_LIMIT),
formatter: formatSheet
},
sheet: {
call: (kw, p) => searchSheetsAPI('playlist', kw, p, SEARCH_LIMIT),
formatter: formatSheet
},
artist: {
call: (kw, p) => searchArtistsAPI(kw, p, SEARCH_LIMIT),
formatter: formatArtist
}
};
async function search(keyword, page, type) {
log('debug', '[search] keyword:', keyword, 'page:', page, 'type:', type);
const entry = searchMap[type];
if (!entry) return { isEnd: true, data: [] };
const response = await entry.call(keyword, page);
const items = (response.results || []).map(entry.formatter);
log('debug', '[search] items count:', items.length);
return {
isEnd: items.length < SEARCH_LIMIT,
data: items
};
}
async function importMusicItem(url) {
log('debug', '[importMusicItem] URL:', url);
if (url.includes('/song/')) {
const response = await getSongAPI(extractTokenFromUrl(url));
const song = formatSong(response.songs[0]);
log('debug', '[importMusicItem] imported song:', song.title);
return song;
}
throw new Error('URL is not a song link');
}
async function importMusicSheet(url) {
log('debug', '[importMusicSheet] URL:', url);
let type;
if (url.includes('/album/')) {
type = 'album';
} else if (url.includes('/featured/') || url.includes('/playlist/')) {
type = 'playlist';
} else {
throw new Error('URL is not an album or playlist link');
}
log('debug', '[importMusicSheet] Type:', type);
const response = await getSheetAPI(type, extractTokenFromUrl(url), 1, 100);
const tracks = formatSheetTracks(response);
log('debug', '[importMusicSheet] tracks count:', tracks.length);
return tracks;
}
async function getTopLists() {
log('debug', '[getTopLists] Fetching trending content...');
// if we pass L param to Cookie, it should respond with that language specific content
// but in MusicFree, it is not working like intended
// keeping it for future reference
const langHeader = getLanguageHeader();
const allItems = await callAPI('content.getCharts', {}, langHeader);
log('debug', '[getTopLists] Received', allItems.length, 'items');
const playlists = allItems.filter(item => item.type === 'playlist');
log('debug', '[getTopLists] Playlists:', playlists.length);
return [
{
title: 'Top Charts',
data: playlists.map(item => {
const sheet = formatSheet(item);
sheet.coverImg = sheet.artwork; // Ensure coverImg is present for top lists
return sheet;
})
}
];
}
let launchDataCache = null;
function hasSheets(data) {
if (!Array.isArray(data)) return false;
return data.some(item => ['album', 'playlist', 'song', 'radio_station'].includes(item.type));
}
async function getRecommendSheetTags() {
log('debug', '[getRecommendSheetTags] Called');
try {
const langHeader = getLanguageHeader();
const data = await callAPI('webapi.getLaunchData', {}, langHeader);
launchDataCache = data;
const modules = data.modules || {};
const tags = [];
for (const key of Object.keys(modules)) {
const module = modules[key];
const topData = data[key];
if (hasSheets(topData)) {
tags.push({
id: key,
title: module.title || key
});
}
}
log('debug', '[getRecommendSheetTags] Found', tags.length, 'tags');
return {
data: [
{
title: 'LaunchData',
data: tags
}
]
};
} catch (e) {
log('error', '[getRecommendSheetTags] Error:', e.message);
return { data: [] };
}
}
async function getRecommendSheetsByTag(tag, page) {
log('debug', '[getRecommendSheetsByTag] Tag:', tag, 'Page:', page);
try {
const langHeader = getLanguageHeader();
if (!launchDataCache) {
log('debug', '[getRecommendSheetsByTag] Cache empty, fetching LaunchData...');
launchDataCache = await callAPI('webapi.getLaunchData', {}, langHeader);
log('debug', '[getRecommendSheetsByTag] LaunchData fetched and cached.');
}
// Handle "Default" tag
let effectiveTagId = tag.id;
if (!effectiveTagId || tag.title === 'Default') {
log('debug', '[getRecommendSheetsByTag] "Default" tag detected, picking first module with sheets');
const modules = launchDataCache.modules || {};
for (const key of Object.keys(modules)) {
const topData = launchDataCache[key];
if (hasSheets(topData)) {
effectiveTagId = key;
log('debug', '[getRecommendSheetsByTag] Mapped "Default" to:', effectiveTagId);
break;
}
}
if (!effectiveTagId || effectiveTagId === tag.id) {
log('warn', '[getRecommendSheetsByTag] No module with sheets found, returning empty');
return { isEnd: true, data: [] };
}
}
const module = launchDataCache.modules?.[effectiveTagId];
if (!module) {
log('warn', '[getRecommendSheetsByTag] Module not found:', effectiveTagId);
return { isEnd: true, data: [] };
}
let items = [];
let isEnd = false;
if (module.view_more && module.view_more.api) {
const viewMore = module.view_more;
const api = viewMore.api;
const pageParam = viewMore.page_param || 'p';
const sizeParam = viewMore.size_param || 'n';
const defaultSize = viewMore.default_size || SEARCH_LIMIT;
log('debug', '[getRecommendSheetsByTag] Fetching via view_more:', api);
const response = await callAPI(api, {
[pageParam]: page || 1,
[sizeParam]: defaultSize
}, langHeader);
const responseData = response[effectiveTagId] || response.data || response.results || response.list || response;
items = Array.isArray(responseData) ? responseData : [];
isEnd = items.length < defaultSize;
} else {
log('debug', '[getRecommendSheetsByTag] Using cached:');
items = launchDataCache[effectiveTagId] || [];
isEnd = true;
}
const sheets = items.filter(item => ['album', 'playlist', 'song', 'radio_station'].includes(item.type));
const formatted = sheets.map(formatSheet);
return { isEnd, data: formatted };
} catch (e) {
log('error', '[getRecommendSheetsByTag] Error:', e.message);
return { isEnd: true, data: [] };
}
}
module.exports = {
platform: 'JioSaavn',
version: '0.3.7',
author: 'Hemanth',
description: `
# JioSaavn Plugin for [MusicFree](https://github.com/maotoumao/MusicFree)
**Play songs, albums, playlists, and artists from India's JioSaavn – directly in MusicFree.**
This plugin brings the entire JioSaavn catalog to MusicFree, with full search, playback, and import support. No official API needed – it works by interacting directly with JioSaavn's public web endpoints.
---
## ✨ Features
- **Search** – Search for songs, albums, playlists, and artists using the tabs in the search bar.
- **Playback** – Decrypted audio streams with quality selection (low, standard, high, super).
- **Album details** – View full tracklists for any album.
- **Playlist details** – Browse tracks inside playlists (with pagination).
- **Artist works** – View an artist's top songs and albums.
- **Lyrics** – Fetch synced lyrics for supported songs (removes \`<br>\` tags for clean display).
- **URL import** – Paste any JioSaavn song, album, or playlist link directly into MusicFree's Import functionality in Plugin Management to import it instantly.
- **Quality selection** – Choose between 48, 96, 160, and 320 kbps (depending on availability).
- **Caching** – Decrypted URLs are cached per song for fast quality switching.
- **Charts** – Browse curated categories like "Trending Now", "Top Charts", "New Releases", and more. All categories are dynamic and pulled from JioSaavn's LaunchData.
- **Recommended Playlists** – Explore albums and playlists through dynamically generated tags (e.g., "Trending Now", "Charts") in the Recommendations section. Pagination is supported where available.
- **Configurable logging** – Set \`logLevel\` in plugin settings to \`none\`, \`error\`, \`warn\`, \`info\`, or \`debug\` to control console output.
---
## 🛠️ How to Use
1. **Search** – Type a song, album, playlist, or artist name into MusicFree's search bar. Use the tabs to filter between \`Single\`, \`Album\`, \`Artist\`, or \`Playlist\`.
2. **Import** – Paste a JioSaavn URL (e.g., \`https://www.jiosaavn.com/song/...\`) using the \`Import Song\` in Plugin Management to instantly import the content.
3. **Play** – Tap a song to start playback. The plugin automatically decrypts the audio stream.
4. **Quality** – If your MusicFree version supports it, you can change quality in the playback settings.
5. **Charts** – Open the "Charts" section in MusicFree to see curated categories and browse albums/playlists.
6. **Recommended Playlists** – Open the "Recommend Playlists" section to discover albums and playlists based on dynamic tags.
---
## 🔧 Plugin Settings
| Setting | Description |
|---------|-------------|
| \`Log Level\` | Control how much debug output the plugin prints. Options: \`none\`, \`error\`, \`warn\`, \`info\`, \`debug\`. Default: \`error\`. |
---
## 📦 Notes
- Decryption is handled locally using \`crypto-js\` – no third‑party servers are involved.
- The plugin respects your privacy – no tracking or analytics.
- All API calls are made directly to JioSaavn's public endpoints.
- Charts and Recommendations are powered by JioSaavn's \`webapi.getLaunchData\` endpoint, providing dynamic and up‑to‑date categories.
---
## 🐛 Reporting Issues
If you encounter a bug, please include:
1. The action you were performing (search, play, import, etc.).
2. The \`logLevel\` set to \`debug\` and the relevant console output.
3. The MusicFree version and your device/OS.
---
## 🙏 Credits
- Built for the [MusicFree](https://github.com/maotoumao/MusicFree) ecosystem.
- JioSaavn content is property of its respective owners.
### Enjoy the music! 🎵
`,
srcUrl: "https://gist.githubusercontent.com/HemanthJabalpuri/79a05fbbdee5be2a1760977b5f18ea95/raw",
cacheControl: "cache",
search,
supportedSearchType: ['music', 'album', 'sheet', 'artist'],
getLyric,
getAlbumInfo: (item, page) => getSheetInfo(item, page),
getMusicSheetInfo: (item, page) => getSheetInfo(item, page),
getArtistWorks,
getMediaSource,
getTopLists,
getTopListDetail: (item, page) => getSheetInfo(item, page),
getRecommendSheetTags,
getRecommendSheetsByTag,
// import songs
importMusicItem,
importMusicSheet,
hints: {
importMusicItem: [
"1. Open a song in the JioSaavn app or website.",
"2. Tap Share and select 'Copy link'.",
"3. Paste the link into the MusicFree search bar."
],
importMusicSheet: [
"1. Open an album or playlist in the JioSaavn app or website.",
"2. Tap Share and select 'Copy link'.",
"3. Paste the link into the MusicFree search bar."
]
},
userVariables: [
{ key: "logLevel", name: "Log Level", hint: "debug/none" },
{ key: "languages", name: "Languages", hint: "hindi,telugu" },
]
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment