Build A Plugin From Scratch
This is the first stop for plugin developers. It builds a complete Lyrico plugin from scratch and shows how the manifest, plugin functions, config fields, result fields, helper scripts, and packaging work together.
The manifest only declares plugin identity, entry, capabilities, and required configuration. Plugins should return host-standard metadata through fields and platform-private context through internal.
The example plugin is named "MusicLib" and connects to a fictional https://api.musiclib.example.com music API.
Plugin Goals
- Support song search, lyrics retrieval, and cover search
- Authenticate with a user-configured API key
- Return standard metadata such as title, artist, album, and cover
- Provide configurable timeout, region, and cover size options
Directory Structure
com.musiclib.source/
├── manifest.json
├── source.js
├── icon.png
└── lib/
├── 01_api.js
└── 02_lyrics.jsmanifest.json
{
"id": "com.musiclib.source",
"name": "MusicLib",
"versionCode": 1,
"versionName": "1.0.0",
"author": "Your Name",
"description": "MusicLib music API search source",
"apiVersion": 3,
"entry": "source.js",
"includeDirs": ["lib"],
"icon": "icon.png",
"capabilities": ["searchSongs", "getLyrics", "searchCovers"],
"configFields": [
{
"key": "api_key",
"title": "API Key",
"summary": "MusicLib API access key",
"group": "Auth",
"type": "password",
"required": true,
"defaultValue": ""
},
{
"key": "region",
"title": "Region",
"summary": "API request region",
"group": "Request",
"type": "dropdown",
"required": true,
"defaultValue": "cn",
"options": [
{ "value": "cn", "label": "Mainland China" },
{ "value": "us", "label": "United States" },
{ "value": "jp", "label": "Japan" }
]
},
{
"key": "timeout",
"title": "Timeout (seconds)",
"summary": "HTTP request timeout",
"group": "Request",
"type": "number",
"defaultValue": "15"
},
{
"key": "cover_size",
"title": "Cover Size",
"group": "Cover",
"type": "dropdown",
"required": true,
"defaultValue": "800",
"options": [
{ "value": "300", "label": "300 × 300" },
{ "value": "800", "label": "800 × 800" },
{ "value": "1200", "label": "1200 × 1200" }
]
}
]
}lib/01_api.js — API Layer
var MusicLib = MusicLib || {};
MusicLib.BASE_URL = "https://api.musiclib.example.com/v1";
MusicLib.getConfig = function (request) {
var config = request.config || {};
return {
apiKey: config.api_key || "",
region: config.region || "cn",
timeout: parseInt(config.timeout || "15", 10) * 1000,
coverSize: config.cover_size || "800"
};
};
MusicLib.buildHeaders = function (config) {
return {
"X-API-Key": config.apiKey,
"X-Region": config.region,
"Accept": "application/json"
};
};
MusicLib.signRequest = function (path, params, config) {
var keys = Object.keys(params).sort();
var raw = path;
for (var i = 0; i < keys.length; i++) {
raw += keys[i] + String(params[keys[i]]);
}
raw += config.apiKey;
return Platform.crypto.md5(raw);
};
MusicLib.get = function (path, params, config) {
var url = MusicLib.BASE_URL + path;
var queryParts = [];
var keys = Object.keys(params || {});
for (var i = 0; i < keys.length; i++) {
queryParts.push(
encodeURIComponent(keys[i]) + "=" + encodeURIComponent(params[keys[i]])
);
}
if (queryParts.length > 0) {
url += "?" + queryParts.join("&");
}
var signature = MusicLib.signRequest(path, params, config);
var headers = MusicLib.buildHeaders(config);
headers["X-Signature"] = signature;
return JSON.parse(
Platform.http.getText(url, {
headers: headers,
readTimeoutMs: config.timeout
})
);
};
MusicLib.buildCoverUrl = function (coverId, config) {
if (!coverId) return "";
return (
"https://img.musiclib.example.com/covers/" +
coverId +
"_" +
config.coverSize +
"x" +
config.coverSize +
".jpg"
);
};lib/02_lyrics.js — Lyrics Parsing
MusicLib.parsePlainLrc = function (lrcText) {
if (!lrcText || typeof lrcText !== "string") {
return [];
}
var lines = lrcText.split("\n");
var result = [];
var tagRegex = /\[(\d+):(\d+(?:\.\d+)?)\](.*)/;
var timeRegex = /\[(\d+):(\d+(?:\.\d+)?)\]/g;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var match = line.match(tagRegex);
if (!match) continue;
var minutes = parseInt(match[1], 10);
var seconds = parseFloat(match[2]);
var text = (match[3] || "").trim();
if (!text) continue;
var startMs = Math.round((minutes * 60 + seconds) * 1000);
var endMs = startMs + 3000;
result.push([startMs, endMs, text]);
}
return result;
};
MusicLib.mapLyrics = function (apiResponse) {
var lyrics = apiResponse.data && apiResponse.data.lyrics;
if (!lyrics) return null;
var rawLrc = lyrics.original || "";
var translatedLrc = lyrics.translated || "";
var roma = lyrics.romanization || "";
if (!rawLrc && !translatedLrc) return null;
var original = MusicLib.parsePlainLrc(rawLrc);
var translated = translatedLrc
? MusicLib.parsePlainLrc(translatedLrc)
: null;
var romanization = roma ? MusicLib.parsePlainLrc(roma) : null;
return {
type: "structured",
tags: {
ti: lyrics.title || "",
ar: lyrics.artist || "",
al: lyrics.album || ""
},
original: original,
translated: translated,
romanization: romanization
};
};source.js — Entry File
function formatDate(timestamp) {
if (!timestamp) return "";
var date = new Date(timestamp);
var y = date.getFullYear();
var m = String(date.getMonth() + 1).padStart(2, "0");
var d = String(date.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function mapSong(item, request) {
var config = MusicLib.getConfig(request);
var coverUrl = MusicLib.buildCoverUrl(item.cover_id, config);
var fields = {
title: item.name || "",
artist: (Array.isArray(item.artists) ? item.artists : [])
.map(function (a) { return a.name || ""; })
.filter(function (n) { return n; })
.join(request.separator || "/"),
album: (item.album || {}).name || "",
date: formatDate(item.release_time * 1000),
track_number: String(item.track_number || ""),
cover_url: coverUrl
};
var internal = {
musiclib_id: String(item.id || "")
};
return {
id: String(item.id || ""),
title: fields.title,
artist: fields.artist,
album: fields.album,
duration: Number(item.duration_ms || 0),
date: fields.date,
trackNumber: fields.track_number,
picUrl: coverUrl,
fields: fields,
internal: internal
};
}
function searchSongs(request) {
try {
var config = MusicLib.getConfig(request);
var page = Math.max(1, Number(request.page || 1));
var pageSize = Number(request.pageSize || 20);
var response = MusicLib.get("/search", {
q: request.keyword || "",
page: page,
limit: pageSize,
region: config.region
}, config);
var items = (response.data && response.data.items) || [];
return JSON.stringify(
items
.map(function (item) { return mapSong(item, request); })
.filter(function (song) { return song.id && song.title; })
);
} catch (e) {
Platform.log.error(
"MusicLib",
"searchSongs failed: " + (e && e.message ? e.message : e)
);
return JSON.stringify([]);
}
}
function getLyrics(request) {
var song = request.song || {};
var internal = song.internal || {};
var trackId = internal.musiclib_id || song.id || "";
if (!trackId) return null;
try {
var config = MusicLib.getConfig(request);
var response = MusicLib.get("/lyrics", { id: trackId }, config);
var lyricsResult = MusicLib.mapLyrics(response);
if (!lyricsResult) return null;
lyricsResult.tags = lyricsResult.tags || {};
lyricsResult.tags.ti = lyricsResult.tags.ti || song.title || "";
lyricsResult.tags.ar = lyricsResult.tags.ar || song.artist || "";
lyricsResult.tags.al = lyricsResult.tags.al || song.album || "";
return JSON.stringify(lyricsResult);
} catch (e) {
Platform.log.warn(
"MusicLib",
"getLyrics failed: " + (e && e.message ? e.message : e)
);
return null;
}
}
function searchCovers(request) {
var songs = JSON.parse(
searchSongs({
keyword: request.keyword,
page: 1,
pageSize: request.pageSize || 5,
separator: "/",
config: request.config || {}
})
);
return JSON.stringify(
songs.filter(function (song) { return song.picUrl; })
);
}Package
After arranging the files above, package them as a ZIP:
MusicLib-v1.0.0.zip
└── com.musiclib.source/
├── manifest.json
├── source.js
├── icon.png
└── lib/
├── 01_api.js
└── 02_lyrics.jsThe ZIP root level should be the plugin root directory, com.musiclib.source/. Do not add an extra outer directory.
Import Validation
After importing the ZIP into Lyrico, the system validates it as follows:
- Extract the ZIP to a temporary directory
- Find
com.musiclib.source/manifest.json - Validate
idformat, matchingapiVersion, and legalcapabilities - Validate that
source.jsexists, uses.js, and has a valid size - Validate that
lib/exists - Validate that
icon.pngexists and has a supported format - If all checks pass, install it to
plugins/sources/com.musiclib.source/
FAQ
Q: How do I debug a plugin?
Use Platform.log.debug() to write logs to Android Logcat:
Platform.log.debug("MusicLib", "Request URL: " + url);
Platform.log.debug("MusicLib", "Response: " + JSON.stringify(response).substring(0, 200));Filter Logcat by PlatformPlugin or a custom tag such as "MusicLib".
Q: How do I handle pagination?
searchSongs receives page and pageSize. Forward them to the API:
var page = Math.max(1, Number(request.page || 1));
var pageSize = Number(request.pageSize || 20);
var offset = (page - 1) * pageSize;Q: How do I support multiple artists?
Join multiple artists with request.separator:
var artist = artists
.map(function (a) { return a.name || ""; })
.filter(function (n) { return n; })
.join(request.separator || "/");Q: How can the entry file access helper scripts before it runs?
All helper scripts are concatenated before the entry script and executed first. Put shared logic on a global object, such as window or your own namespace:
// lib/01_api.js
var MusicLib = MusicLib || {};
MusicLib.BASE_URL = "...";
// source.js — can use it directly
var data = MusicLib.get("/search", params, config);