mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 09:01:33 +02:00
5872 lines
214 KiB
HTML
5872 lines
214 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Documentation - SpotiFLAC Mobile</title>
|
|
<meta name="description" content="SpotiFLAC Extension Development Guide with full API reference, examples, and implementation details.">
|
|
<meta name="theme-color" content="#0a0a0a">
|
|
<link rel="icon" href="icon.png" type="image/png">
|
|
|
|
<style>
|
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 600; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-600-normal.woff2) format('woff2'); }
|
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 700; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-700-normal.woff2) format('woff2'); }
|
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 800; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-800-normal.woff2) format('woff2'); }
|
|
</style>
|
|
|
|
<style>
|
|
/* ── M3 AMOLED surface ramp ── */
|
|
:root {
|
|
--green: #1DB954;
|
|
--green-dim: #1aa34a;
|
|
--bg: #0a0a0a; /* surfaceContainerLow */
|
|
--bg-card: #1a1a1a; /* surfaceContainerHigh */
|
|
--bg-card-hover: #222222; /* surfaceContainerHighest */
|
|
--surface: #121212; /* surfaceContainer */
|
|
--text: #e8e8e8; /* onSurface */
|
|
--text-dim: #999; /* onSurfaceVariant */
|
|
--max-w: 1440px;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: #333 transparent; }
|
|
html::-webkit-scrollbar { width: 8px; }
|
|
html::-webkit-scrollbar-track { background: transparent; }
|
|
html::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
|
html::-webkit-scrollbar-thumb:hover { background: #555; }
|
|
body {
|
|
font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
background: var(--bg); color: var(--text); line-height: 1.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
a { color: var(--green); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
|
|
nav {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 100;
|
|
background: rgba(18,18,18,.78);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
}
|
|
.nav-inner {
|
|
max-width: 1100px;
|
|
margin: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 24px;
|
|
height: 64px;
|
|
}
|
|
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; color: var(--text); }
|
|
.nav-brand img { width: 32px; height: 32px; border-radius: 50%; }
|
|
.nav-links { display: flex; align-items: center; gap: 24px; list-style: none; }
|
|
.nav-links a { color: var(--text-dim); font-size: .9rem; transition: color .2s; }
|
|
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
|
.nav-links a.active { color: var(--text); font-weight: 600; }
|
|
.nav-links .nav-icon { display: flex; align-items: center; opacity: .6; transition: opacity .2s; margin-left: -12px; }
|
|
.nav-links .nav-icon:hover { opacity: 1; }
|
|
.nav-links .nav-icon svg { width: 24px; height: 24px; fill: currentColor; }
|
|
.nav-links .nav-divider { width: 1px; height: 20px; background: rgba(255,255,255,.15); margin-left: -4px; }
|
|
|
|
.nav-burger {
|
|
display: none;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 12px;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
.nav-burger .bar {
|
|
display: block;
|
|
width: 20px;
|
|
height: 2px;
|
|
background: var(--text);
|
|
border-radius: 2px;
|
|
transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .2s;
|
|
position: absolute;
|
|
left: 10px;
|
|
}
|
|
.nav-burger .bar:nth-child(1) { top: 12px; }
|
|
.nav-burger .bar:nth-child(2) { top: 19px; }
|
|
.nav-burger .bar:nth-child(3) { top: 26px; }
|
|
.nav-burger.active .bar:nth-child(1) { top: 19px; transform: rotate(45deg); }
|
|
.nav-burger.active .bar:nth-child(2) { opacity: 0; }
|
|
.nav-burger.active .bar:nth-child(3) { top: 19px; transform: rotate(-45deg); }
|
|
.mobile-overlay {
|
|
position: fixed;
|
|
top: 64px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,.5);
|
|
z-index: 98;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
|
}
|
|
.mobile-overlay.open { opacity: 1; pointer-events: auto; }
|
|
.mobile-menu {
|
|
position: fixed;
|
|
top: 64px;
|
|
left: 0;
|
|
right: 0;
|
|
background: rgba(18,18,18,.95);
|
|
padding: 8px 16px 16px;
|
|
z-index: 99;
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
transform: translateY(-8px);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .3s cubic-bezier(.4,0,.2,1);
|
|
}
|
|
.mobile-menu.open { transform: translateY(0); opacity: 1; pointer-events: auto; }
|
|
.mobile-menu a {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border-radius: 12px;
|
|
color: var(--text-dim);
|
|
font-size: .95rem;
|
|
font-weight: 500;
|
|
transition: background .2s;
|
|
opacity: 0;
|
|
transform: translateY(-6px);
|
|
}
|
|
.mobile-menu.open a {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
transition: background .2s, opacity .3s cubic-bezier(.4,0,.2,1), transform .3s cubic-bezier(.4,0,.2,1);
|
|
}
|
|
.mobile-menu.open a:nth-child(1) { transition-delay: .03s; }
|
|
.mobile-menu.open a:nth-child(2) { transition-delay: .06s; }
|
|
.mobile-menu.open a:nth-child(3) { transition-delay: .09s; }
|
|
.mobile-menu.open a:nth-child(4) { transition-delay: .12s; }
|
|
.mobile-menu.open a:nth-child(5) { transition-delay: .15s; }
|
|
.mobile-menu a:hover { background: var(--bg-card); color: var(--text); text-decoration: none; }
|
|
.mobile-menu a.active { color: var(--text); font-weight: 600; background: var(--bg-card); }
|
|
.mobile-menu .mobile-divider {
|
|
height: 1px;
|
|
background: rgba(255,255,255,.06);
|
|
margin: 4px 0;
|
|
opacity: 0;
|
|
transition: opacity .3s .15s;
|
|
}
|
|
.mobile-menu.open .mobile-divider { opacity: 1; }
|
|
.mobile-menu .mobile-icons {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 8px 16px 0;
|
|
opacity: 0;
|
|
transform: translateY(-6px);
|
|
transition: opacity .3s cubic-bezier(.4,0,.2,1) .18s, transform .3s cubic-bezier(.4,0,.2,1) .18s;
|
|
}
|
|
.mobile-menu.open .mobile-icons { opacity: 1; transform: translateY(0); }
|
|
.mobile-menu .mobile-icons a {
|
|
padding: 10px;
|
|
border-radius: 12px;
|
|
background: var(--bg-card);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
|
|
|
.docs-shell {
|
|
max-width: var(--max-w);
|
|
margin: 0 auto;
|
|
padding: 88px 24px 80px;
|
|
display: grid;
|
|
grid-template-columns: 280px minmax(0, 1fr) 240px;
|
|
gap: 14px;
|
|
align-items: start;
|
|
}
|
|
|
|
.docs-sidebar,
|
|
.docs-onpage {
|
|
position: sticky;
|
|
top: 78px;
|
|
max-height: calc(100vh - 92px);
|
|
overflow: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #333 transparent;
|
|
}
|
|
|
|
.sidebar-toggle { display: none; }
|
|
|
|
/* ── MOBILE MENU BAR ── */
|
|
.docs-menu-bar {
|
|
display: none;
|
|
position: sticky;
|
|
top: 64px;
|
|
z-index: 50;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
|
padding: 0 16px;
|
|
height: 44px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.docs-menu-bar button {
|
|
background: none; border: none; cursor: pointer;
|
|
color: var(--text-dim); font-size: .85rem; font-weight: 500;
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 6px 0;
|
|
font-family: inherit;
|
|
}
|
|
.docs-menu-bar button:hover { color: var(--text); }
|
|
.docs-menu-bar button svg { width: 16px; height: 16px; fill: currentColor; }
|
|
|
|
/* ── DOCS DRAWER ── */
|
|
.docs-drawer-overlay {
|
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,.5); z-index: 200;
|
|
opacity: 0; pointer-events: none;
|
|
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
|
}
|
|
.docs-drawer-overlay.open { opacity: 1; pointer-events: auto; }
|
|
.docs-drawer {
|
|
position: fixed; top: 0; left: 0; bottom: 0;
|
|
width: 300px; max-width: 85vw;
|
|
background: var(--surface);
|
|
z-index: 201;
|
|
transform: translateX(-100%);
|
|
transition: transform .3s cubic-bezier(.4,0,.2,1);
|
|
display: flex; flex-direction: column;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin; scrollbar-color: #333 transparent;
|
|
}
|
|
.docs-drawer.open { transform: translateX(0); }
|
|
.docs-drawer-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,.06);
|
|
flex-shrink: 0;
|
|
}
|
|
.docs-drawer-header h3 { font-size: .95rem; font-weight: 700; color: var(--text); }
|
|
.docs-drawer-close {
|
|
background: none; border: none; cursor: pointer;
|
|
color: var(--text-dim); padding: 4px;
|
|
border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
|
transition: background .2s, color .2s;
|
|
}
|
|
.docs-drawer-close:hover { background: var(--bg-card-hover); color: var(--text); }
|
|
.docs-drawer-close svg { width: 20px; height: 20px; }
|
|
.docs-drawer-body {
|
|
padding: 12px 16px; flex: 1; overflow-y: auto;
|
|
scrollbar-width: thin; scrollbar-color: #333 transparent;
|
|
}
|
|
|
|
.panel {
|
|
border-radius: 16px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.panel h2 { font-size: .84rem; letter-spacing: .03em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 12px; }
|
|
|
|
.section-list,
|
|
.section-sub-list { list-style: none; }
|
|
.section-list > li + li { margin-top: 2px; }
|
|
.section-sub-list { margin: 4px 0 10px 10px; }
|
|
|
|
.section-link,
|
|
.onpage-link {
|
|
display: block;
|
|
width: 100%;
|
|
color: var(--text-dim);
|
|
border-radius: 8px;
|
|
padding: 7px 10px;
|
|
transition: background .2s, color .2s;
|
|
line-height: 1.35;
|
|
font-size: .84rem;
|
|
}
|
|
.section-link.level-2 { font-weight: 600; color: #cfcfcf; }
|
|
.section-link.level-3 { font-size: .8rem; padding-left: 12px; }
|
|
.section-link:hover,
|
|
.onpage-link:hover { background: var(--bg-card-hover); color: var(--text); text-decoration: none; }
|
|
.section-link.active,
|
|
.onpage-link.active {
|
|
background: rgba(29,185,84,.12);
|
|
color: var(--green);
|
|
}
|
|
|
|
.onpage-empty { color: #6d6d6d; font-size: .8rem; padding: 4px 10px; }
|
|
.onpage-link.level-2 { font-weight: 600; }
|
|
.onpage-link.level-3 { padding-left: 16px; font-size: .8rem; }
|
|
.onpage-link.level-4 { padding-left: 24px; font-size: .78rem; }
|
|
|
|
.docs-main { min-width: 0; }
|
|
|
|
.docs-content {
|
|
padding: 0 34px;
|
|
min-height: 65vh;
|
|
}
|
|
|
|
.docs-content > *:first-child { margin-top: 0; }
|
|
.docs-content h1,
|
|
.docs-content h2,
|
|
.docs-content h3,
|
|
.docs-content h4 {
|
|
color: var(--text);
|
|
line-height: 1.3;
|
|
letter-spacing: -.01em;
|
|
scroll-margin-top: 84px;
|
|
}
|
|
.docs-content h1 { font-size: 2rem; margin: 4px 0 22px; }
|
|
.docs-content h2 { font-size: 1.4rem; margin: 48px 0 16px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 32px; }
|
|
.docs-content h2:first-of-type { border-top: 0; padding-top: 0; }
|
|
.docs-content h3 { font-size: 1.1rem; margin: 32px 0 12px; }
|
|
.docs-content h4 { font-size: 1rem; margin: 24px 0 10px; color: #d8d8d8; }
|
|
|
|
.docs-content p { margin: 14px 0; color: #d5d5d5; font-size: .94rem; }
|
|
.docs-content ul,
|
|
.docs-content ol { margin: 12px 0 16px 20px; }
|
|
.docs-content li { margin: 6px 0; font-size: .93rem; color: #d3d3d3; }
|
|
.docs-content hr { border: 0; height: 1px; background: rgba(255,255,255,.08); margin: 32px 0; }
|
|
.docs-content blockquote {
|
|
border-left: 3px solid rgba(29,185,84,.6);
|
|
background: rgba(29,185,84,.06);
|
|
padding: 10px 14px;
|
|
border-radius: 12px;
|
|
margin: 18px 0;
|
|
color: #c8e8d4;
|
|
}
|
|
|
|
.docs-content code {
|
|
background: var(--bg-card-hover);
|
|
border: 1px solid rgba(255,255,255,.08);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: .84em;
|
|
color: #d4ffd7;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|
}
|
|
|
|
.docs-content pre {
|
|
margin: 16px 0 20px;
|
|
padding: 15px;
|
|
border-radius: 12px;
|
|
background: #0f0f0f;
|
|
border: 1px solid rgba(255,255,255,.08);
|
|
overflow: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #333 transparent;
|
|
}
|
|
.docs-content pre::-webkit-scrollbar,
|
|
.docs-content table::-webkit-scrollbar { height: 6px; width: 6px; }
|
|
.docs-content pre::-webkit-scrollbar-track,
|
|
.docs-content table::-webkit-scrollbar-track { background: transparent; }
|
|
.docs-content pre::-webkit-scrollbar-thumb,
|
|
.docs-content table::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
|
.docs-content pre::-webkit-scrollbar-thumb:hover,
|
|
.docs-content table::-webkit-scrollbar-thumb:hover { background: #555; }
|
|
.docs-content table { scrollbar-width: thin; scrollbar-color: #333 transparent; }
|
|
.docs-content pre code {
|
|
border: 0;
|
|
background: transparent;
|
|
color: #ebebeb;
|
|
padding: 0;
|
|
font-size: .82rem;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.docs-content table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
margin: 12px 0 16px;
|
|
border: 1px solid rgba(255,255,255,.08);
|
|
font-size: .85rem;
|
|
}
|
|
.docs-content th,
|
|
.docs-content td {
|
|
border: 1px solid rgba(255,255,255,.08);
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
.docs-content th { background: rgba(255,255,255,.03); color: var(--text); font-weight: 600; }
|
|
.docs-content td { color: #d2d2d2; }
|
|
|
|
.docs-content img { max-width: 100%; height: auto; border-radius: 16px; }
|
|
|
|
footer {
|
|
background: var(--surface);
|
|
padding: 40px 24px;
|
|
text-align: center;
|
|
}
|
|
.footer-inner { max-width: 1100px; margin: auto; }
|
|
.footer-links { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
|
|
.footer-links a { color: var(--text-dim); font-size: .9rem; }
|
|
.footer-links a:hover { color: var(--text); }
|
|
.footer-copy { color: #555; font-size: .8rem; }
|
|
|
|
@media (max-width: 1200px) {
|
|
.docs-shell { grid-template-columns: 260px minmax(0, 1fr); }
|
|
.docs-onpage { display: none; }
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
nav { position: relative; }
|
|
.nav-links { display: none; }
|
|
.nav-burger { display: flex; }
|
|
.docs-menu-bar { display: flex; top: 0; }
|
|
.docs-shell { grid-template-columns: 1fr; padding: 0 16px 56px; margin-top: 0; }
|
|
.docs-sidebar { display: none; }
|
|
.docs-content { padding: 0 4px; }
|
|
.panel { border-radius: 12px; }
|
|
.docs-content h1 { font-size: 1.6rem; }
|
|
.docs-content h2 { font-size: 1.25rem; padding-top: 22px; margin-top: 32px; }
|
|
.docs-content h1, .docs-content h2, .docs-content h3, .docs-content h4 { scroll-margin-top: 60px; }
|
|
.docs-content table { display: block; overflow-x: auto; white-space: nowrap; }
|
|
}
|
|
|
|
/* ── SEARCH MODAL ── */
|
|
.search-trigger {
|
|
display: flex; align-items: center; gap: 6px;
|
|
background: rgba(255,255,255,.06);
|
|
border: 1px solid rgba(255,255,255,.12);
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
color: var(--text-dim); font-size: .85rem; cursor: pointer;
|
|
font-family: inherit; transition: color .2s, border-color .2s, background .2s;
|
|
white-space: nowrap;
|
|
}
|
|
.search-trigger:hover { color: var(--text); border-color: rgba(255,255,255,.25); background: rgba(255,255,255,.1); }
|
|
.search-trigger svg { width: 14px; height: 14px; fill: currentColor; flex-shrink: 0; }
|
|
.search-trigger kbd {
|
|
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1);
|
|
border-radius: 4px; padding: 0px 4px; font-size: .65rem;
|
|
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
|
}
|
|
|
|
.search-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
|
z-index: 300; opacity: 0; pointer-events: none;
|
|
transition: opacity .2s cubic-bezier(.4,0,.2,1);
|
|
display: flex; align-items: flex-start; justify-content: center;
|
|
padding-top: min(20vh, 140px);
|
|
}
|
|
.search-overlay.open { opacity: 1; pointer-events: auto; }
|
|
|
|
.search-modal {
|
|
background: var(--surface); border: 1px solid rgba(255,255,255,.1);
|
|
border-radius: 16px; width: 580px; max-width: calc(100vw - 32px);
|
|
max-height: min(70vh, 520px); display: flex; flex-direction: column;
|
|
box-shadow: 0 16px 70px rgba(0,0,0,.6);
|
|
transform: translateY(-12px) scale(.97); opacity: 0;
|
|
transition: transform .25s cubic-bezier(.4,0,.2,1), opacity .2s;
|
|
}
|
|
.search-overlay.open .search-modal { transform: translateY(0) scale(1); opacity: 1; }
|
|
|
|
.search-header {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.08);
|
|
}
|
|
.search-header svg { width: 18px; height: 18px; fill: var(--text-dim); flex-shrink: 0; }
|
|
.search-input {
|
|
flex: 1; background: none; border: none; outline: none;
|
|
color: var(--text); font-size: .95rem; font-family: inherit;
|
|
}
|
|
.search-input::placeholder { color: var(--text-dim); }
|
|
.search-esc {
|
|
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1);
|
|
border-radius: 4px; padding: 2px 7px; font-size: .72rem;
|
|
color: var(--text-dim); font-family: inherit; cursor: pointer;
|
|
}
|
|
.search-esc:hover { background: rgba(255,255,255,.14); }
|
|
|
|
.search-body { overflow-y: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: #333 transparent; }
|
|
.search-body::-webkit-scrollbar { width: 6px; }
|
|
.search-body::-webkit-scrollbar-track { background: transparent; }
|
|
.search-body::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
|
|
|
.search-group-label {
|
|
padding: 8px 10px 4px; font-size: .72rem; font-weight: 600;
|
|
color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em;
|
|
}
|
|
.search-item {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 12px; border-radius: 10px; cursor: pointer;
|
|
color: var(--text); font-size: .88rem; transition: background .15s;
|
|
}
|
|
.search-item:hover, .search-item.active { background: rgba(255,255,255,.07); }
|
|
.search-item svg { width: 16px; height: 16px; fill: var(--text-dim); flex-shrink: 0; }
|
|
.search-item-text { flex: 1; min-width: 0; }
|
|
.search-item-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.search-item-section { font-size: .78rem; color: var(--text-dim); margin-top: 1px; }
|
|
.search-item mark { background: rgba(29,185,84,.25); color: var(--text); border-radius: 2px; padding: 0 1px; }
|
|
.search-item .search-enter { color: var(--text-dim); font-size: .72rem; opacity: 0; transition: opacity .15s; }
|
|
.search-item.active .search-enter { opacity: 1; }
|
|
|
|
.search-empty {
|
|
padding: 32px 16px; text-align: center; color: var(--text-dim); font-size: .9rem;
|
|
}
|
|
.search-footer {
|
|
padding: 10px 16px; border-top: 1px solid rgba(255,255,255,.06);
|
|
display: flex; align-items: center; gap: 16px; font-size: .72rem; color: #555;
|
|
}
|
|
.search-footer kbd {
|
|
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.08);
|
|
border-radius: 3px; padding: 1px 4px; font-family: inherit; font-size: .68rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.search-trigger kbd { display: none; }
|
|
.search-overlay { padding-top: 16px; }
|
|
.search-modal { max-height: 80vh; border-radius: 14px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav>
|
|
<div class="nav-inner">
|
|
<a class="nav-brand" href="index">
|
|
<img src="icon.png" alt="SpotiFLAC">
|
|
SpotiFLAC Mobile
|
|
</a>
|
|
<ul class="nav-links">
|
|
<li><a href="index#features">Features</a></li>
|
|
<li><a href="downloads">Downloads</a></li>
|
|
<li><a href="index#faq">FAQ</a></li>
|
|
<li><a href="partners">Partners</a></li>
|
|
<li><a href="docs" class="active">Docs</a></li>
|
|
<li class="nav-divider"></li>
|
|
<li><button class="search-trigger" onclick="openSearch()" aria-label="Search documentation">
|
|
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
|
Search
|
|
<kbd id="searchShortcutHint">Ctrl K</kbd>
|
|
</button></li>
|
|
<li><a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" class="nav-icon" aria-label="GitHub"><svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.82-.26.82-.57L9 20.86c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22l-.01 3.29c0 .31.21.69.82.57A12 12 0 0 0 12 .3"/></svg></a></li>
|
|
<li><a href="https://t.me/spotiflac" target="_blank" class="nav-icon" aria-label="Telegram"><svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg></a></li>
|
|
</ul>
|
|
<button class="nav-burger" onclick="toggleMenu()" aria-label="Menu">
|
|
<span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
|
<div class="mobile-menu" id="mobileMenu">
|
|
<a href="index#features">Features</a>
|
|
<a href="downloads">Downloads</a>
|
|
<a href="index#faq">FAQ</a>
|
|
<a href="partners">Partners</a>
|
|
<a href="docs" class="active">Docs</a>
|
|
<div class="mobile-divider"></div>
|
|
<div class="mobile-icons">
|
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" aria-label="GitHub">
|
|
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
|
</a>
|
|
<a href="https://t.me/spotiflac" target="_blank" aria-label="Telegram">
|
|
<svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile menu bar -->
|
|
<div class="docs-menu-bar">
|
|
<button onclick="openDrawer()">
|
|
<svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
|
Menu
|
|
</button>
|
|
<button onclick="openSearch()">
|
|
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
|
Search
|
|
</button>
|
|
<button onclick="window.scrollTo({top:0,behavior:'smooth'})">Return to top</button>
|
|
</div>
|
|
|
|
<!-- Drawer overlay + sidebar -->
|
|
<div class="docs-drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
|
|
<div class="docs-drawer" id="docsDrawer">
|
|
<div class="docs-drawer-header">
|
|
<h3>Sections</h3>
|
|
<button class="docs-drawer-close" onclick="closeDrawer()" aria-label="Close">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="docs-drawer-body" id="drawerBody"></div>
|
|
</div>
|
|
|
|
<div class="docs-shell">
|
|
<aside class="docs-sidebar" aria-label="Sections">
|
|
<div class="panel">
|
|
<h2>Sections</h2>
|
|
<div id="sectionNav">
|
|
<ul class="section-list">
|
|
<li><a class="section-link level-2" data-level="2" href="#table-of-contents">Table of Contents</a>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#introduction">Introduction</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#requirements">Requirements</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#extension-structure">Extension Structure</a>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#manifest-file">Manifest File</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#complete-manifest-example">Complete Manifest Example</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#manifest-fields">Manifest Fields</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#quality-options">Quality Options</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#quality-specific-settings">Quality-Specific Settings</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#permissions">Permissions</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#extension-types">Extension Types</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#settings">Settings</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#button-setting-type">Button Setting Type</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#custom-search-behavior">Custom Search Behavior</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#custom-url-handler">Custom URL Handler</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#album--playlist-functions-v301">Album & Playlist Functions (v3.0.1+)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#artist-support">Artist Support</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#home-feed-support">Home Feed Support</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#track-enrichment">Track Enrichment</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#custom-track-matching">Custom Track Matching</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#post-processing-hooks">Post-Processing Hooks</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#lyrics-provider">Lyrics Provider</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#main-script">Main Script</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#basic-structure">Basic Structure</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#important-registerextension">Important: registerExtension()</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#api-reference">API Reference</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#http-api">HTTP API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#browser-like-polyfills">Browser-like Polyfills</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#storage-api">Storage API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#file-api">File API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#logging-api">Logging API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#utility-api">Utility API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#go-backend-api">Go Backend API</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#credentials-api-encrypted">Credentials API (Encrypted)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#auth-api-oauth-support">Auth API (OAuth Support)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#pkce-oauth-flow-recommended">PKCE OAuth Flow (Recommended)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#crypto-utilities">Crypto Utilities</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#ffmpeg-api-post-processing">FFmpeg API (Post-Processing)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#track-matching-api">Track Matching API</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#extension-examples">Extension Examples</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#example-1-simple-metadata-provider">Example 1: Simple Metadata Provider</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#example-2-download-provider-with-auth">Example 2: Download Provider with Auth</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#example-3-lyrics-provider">Example 3: Lyrics Provider</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#packaging--distribution">Packaging & Distribution</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#project-structure">Project Structure</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#module-system-limitation">Module System Limitation</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#creating-extension-file">Creating Extension File</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#installing-extension">Installing Extension</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#upgrading-extension">Upgrading Extension</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#troubleshooting">Troubleshooting</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#error-extension-did-not-call-registerextension">Error: "extension did not call registerExtension()"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-permission-denied-for-domain-x--network-access-denied">Error: "Permission denied for domain X" / "network access denied"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-post-body-is-object-object">Error: "POST body is [object Object]"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-function-x-is-not-defined">Error: "Function X is not defined"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-invalid-manifest">Error: "Invalid manifest"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#extension-doesnt-appear-after-install">Extension doesn't appear after install</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#http-request-fails">HTTP request fails</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#download-fails">Download fails</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-file-access-denied-extension-does-not-have-file-permission">Error: "file access denied: extension does not have 'file' permission"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-file-access-denied-absolute-paths-are-not-allowed">Error: "file access denied: absolute paths are not allowed"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-file-access-denied-path-x-is-outside-sandbox">Error: "file access denied: path 'X' is outside sandbox"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-cannot-downgrade-extension">Error: "Cannot downgrade extension"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-extension-is-already-installed">Error: "Extension is already installed"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#error-timeout-extension-took-too-long-to-respond">Error: "timeout: extension took too long to respond"</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#thumbnails-not-showing-correctly-in-search-results">Thumbnails not showing correctly in search results</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#technical-details--behavior">Technical Details & Behavior</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#token-refresh-handling">Token Refresh Handling</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#storage-limits">Storage Limits</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#file-api-path-resolution">File API Path Resolution</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#http-redirect-handling">HTTP Redirect Handling</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#standard-error-types">Standard Error Types</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#http-timeout">HTTP Timeout</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#tips--best-practices">Tips & Best Practices</a>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#authentication-api">Authentication API</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#auth-api-reference">Auth API Reference</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#credentials-api-encrypted-storage">Credentials API (Encrypted Storage)</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#crypto-utilities-1">Crypto Utilities</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#oauth-flow-example">OAuth Flow Example</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#data-schema-reference">Data Schema Reference</a>
|
|
<ul class="section-sub-list">
|
|
<li><a class="section-link level-3" data-level="3" href="#track-object">Track Object</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#album-object">Album Object</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#artist-object">Artist Object</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#download-result-object">Download Result Object</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#lyrics-result-object">Lyrics Result Object</a></li>
|
|
<li><a class="section-link level-3" data-level="3" href="#skip-metadata-enrichment">Skip Metadata Enrichment</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#changelog">Changelog</a>
|
|
</li>
|
|
<li><a class="section-link level-2" data-level="2" href="#support">Support</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="docs-main">
|
|
<article class="docs-content" id="docContent">
|
|
<h1 id="spotiflac-extension-development-guide">SpotiFLAC Extension Development Guide</h1>
|
|
<p>A complete guide for creating SpotiFLAC extensions.</p>
|
|
<h2 id="table-of-contents">Table of Contents</h2>
|
|
<ol>
|
|
<li><a href="#introduction">Introduction</a></li>
|
|
<li><a href="#extension-structure">Extension Structure</a></li>
|
|
<li><a href="#manifest-file">Manifest File</a>
|
|
<ul>
|
|
<li><a href="#quality-options">Quality Options</a></li>
|
|
<li><a href="#settings">Settings</a></li>
|
|
<li><a href="#custom-search-behavior">Custom Search Behavior</a></li>
|
|
<li><a href="#custom-url-handler">Custom URL Handler</a></li>
|
|
<li><a href="#artist-support">Artist Support</a></li>
|
|
<li><a href="#home-feed-support">Home Feed Support</a></li>
|
|
<li><a href="#track-enrichment">Track Enrichment</a></li>
|
|
<li><a href="#custom-track-matching">Custom Track Matching</a></li>
|
|
<li><a href="#post-processing-hooks">Post-Processing Hooks</a></li>
|
|
<li><a href="#lyrics-provider">Lyrics Provider</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#main-script">Main Script</a></li>
|
|
<li><a href="#api-reference">API Reference</a></li>
|
|
<li><a href="#extension-examples">Extension Examples</a></li>
|
|
<li><a href="#packaging--distribution">Packaging & Distribution</a>
|
|
<ul>
|
|
<li><a href="#installing-extension">Installing Extension</a></li>
|
|
<li><a href="#upgrading-extension">Upgrading Extension</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
|
<li><a href="#technical-details--behavior">Technical Details & Behavior</a>
|
|
<ul>
|
|
<li><a href="#token-refresh-handling">Token Refresh Handling</a></li>
|
|
<li><a href="#storage-limits">Storage Limits</a></li>
|
|
<li><a href="#file-api-path-resolution">File API Path Resolution</a></li>
|
|
<li><a href="#http-redirect-handling">HTTP Redirect Handling</a></li>
|
|
<li><a href="#standard-error-types">Standard Error Types</a></li>
|
|
<li><a href="#http-timeout">HTTP Timeout</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#tips--best-practices">Tips & Best Practices</a></li>
|
|
<li><a href="#authentication-api">Authentication API</a>
|
|
<ul>
|
|
<li><a href="#auth-api-oauth-support">Basic OAuth</a></li>
|
|
<li><a href="#pkce-oauth-flow-recommended">PKCE OAuth Flow</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#data-schema-reference">Data Schema Reference</a></li>
|
|
</ol>
|
|
<hr />
|
|
<h2 id="introduction">Introduction</h2>
|
|
<p>SpotiFLAC extensions allow you to add:</p>
|
|
<ul>
|
|
<li><strong>Metadata Provider</strong>: New track/album/artist search sources</li>
|
|
<li><strong>Download Provider</strong>: New audio download sources</li>
|
|
<li><strong>Lyrics Provider</strong>: Custom lyrics sources (synced or plain text)</li>
|
|
</ul>
|
|
<p>Extensions are written in <strong>JavaScript</strong> and run in a secure sandbox.</p>
|
|
<h3 id="requirements">Requirements</h3>
|
|
<ul>
|
|
<li>Basic JavaScript knowledge</li>
|
|
<li>Text editor (VS Code, Notepad++, etc.)</li>
|
|
<li>Tool for creating ZIP files</li>
|
|
</ul>
|
|
<hr />
|
|
<h2 id="extension-structure">Extension Structure</h2>
|
|
<p>An extension is a ZIP file with the <code>.spotiflac-ext</code> extension containing:</p>
|
|
<pre><code>my-extension.spotiflac-ext (ZIP)
|
|
├── manifest.json # Required: Metadata and configuration
|
|
├── index.js # Required: Main JavaScript code
|
|
└── icon.png # Optional: Extension icon (PNG, 128x128 recommended)
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="manifest-file">Manifest File</h2>
|
|
<p>The <code>manifest.json</code> file contains extension metadata and configuration.</p>
|
|
<h3 id="complete-manifest-example">Complete Manifest Example</h3>
|
|
<pre><code class="language-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
|
|
}
|
|
]
|
|
}
|
|
</code></pre>
|
|
<h3 id="manifest-fields">Manifest Fields</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Unique extension ID (lowercase, no spaces)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>displayName</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Display name for the extension</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>version</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Version (format: x.y.z)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>description</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Short description</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>author</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Creator name</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>homepage</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Homepage/repository URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>icon</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Icon filename (e.g., "icon.png")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>permissions</code></td>
|
|
<td>object</td>
|
|
<td>Yes</td>
|
|
<td>Access rights definition (<code>network</code>, <code>storage</code>)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>type</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Extension type (<code>metadata_provider</code>, <code>download_provider</code>, <code>lyrics_provider</code>)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>settings</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>User configuration</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>qualityOptions</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>Custom quality options for download providers (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>skipMetadataEnrichment</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>If true, skip metadata enrichment from Deezer/Spotify (use metadata from extension)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>skipBuiltInFallback</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>If true, don't fallback to built-in providers (Tidal/Qobuz/Deezer) when extension download fails</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>minAppVersion</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Minimum SpotiFLAC version required (e.g., "1.0.0")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>searchBehavior</code></td>
|
|
<td>object</td>
|
|
<td>No</td>
|
|
<td>Custom search behavior configuration (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>urlHandler</code></td>
|
|
<td>object</td>
|
|
<td>No</td>
|
|
<td>Custom URL handling configuration (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>trackMatching</code></td>
|
|
<td>object</td>
|
|
<td>No</td>
|
|
<td>Custom track matching configuration (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>postProcessing</code></td>
|
|
<td>object</td>
|
|
<td>No</td>
|
|
<td>Post-processing hooks configuration (see below)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h3 id="quality-options">Quality Options</h3>
|
|
<p>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).</p>
|
|
<pre><code class="language-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"
|
|
}
|
|
]
|
|
</code></pre>
|
|
<p><strong>Quality Option Fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Unique identifier passed to download function</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>label</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Display name shown in the UI</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>description</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Additional info (e.g., bitrate, format)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>settings</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>Quality-specific settings (see below)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p>If <code>qualityOptions</code> is not specified, a default "Default Quality" option will be shown.</p>
|
|
<h3 id="quality-specific-settings">Quality-Specific Settings</h3>
|
|
<p>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).</p>
|
|
<pre><code class="language-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"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
</code></pre>
|
|
<p>In your extension code, access quality-specific settings like this:</p>
|
|
<pre><code class="language-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
|
|
}
|
|
</code></pre>
|
|
<p><strong>Quality-Specific Setting Fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>key</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Setting key (accessed via <code>settings.qualitySettings[quality][key]</code>)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>type</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td><code>string</code>, <code>number</code>, <code>boolean</code>, or <code>select</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>label</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Display name in settings UI</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>description</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Help text for the setting</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>required</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>Whether the setting is required</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>secret</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>If true, input will be masked (for API keys)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>default</code></td>
|
|
<td>any</td>
|
|
<td>No</td>
|
|
<td>Default value</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>options</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>Options for <code>select</code> type</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h3 id="permissions">Permissions</h3>
|
|
<p>Extensions must declare the resources they need:</p>
|
|
<pre><code class="language-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)
|
|
}
|
|
</code></pre>
|
|
<p><strong>Permission Types:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Permission</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>network</code></td>
|
|
<td>array</td>
|
|
<td>List of allowed domains for HTTP requests</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>storage</code></td>
|
|
<td>boolean</td>
|
|
<td>Access to key-value storage API</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>file</code></td>
|
|
<td>boolean</td>
|
|
<td>Access to file operations (read, write, download)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Important Notes:</strong></p>
|
|
<ul>
|
|
<li>Only declared domains can be accessed via HTTP</li>
|
|
<li>Requests to other domains will be blocked</li>
|
|
<li>File operations are sandboxed to extension's data directory</li>
|
|
<li>Absolute paths are blocked for security (only relative paths allowed)</li>
|
|
<li>Download providers should set <code>file: true</code> to save downloaded files</li>
|
|
</ul>
|
|
<h3 id="extension-types">Extension Types</h3>
|
|
<p>Specify the features provided by the extension through the <code>type</code> field:</p>
|
|
<pre><code class="language-json">"type": [
|
|
"metadata_provider", // Provides search/metadata
|
|
"download_provider", // Provides downloads
|
|
"lyrics_provider" // Provides lyrics (synced or plain)
|
|
]
|
|
</code></pre>
|
|
<h3 id="settings">Settings</h3>
|
|
<p>Define user-configurable settings:</p>
|
|
<pre><code class="language-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
|
|
}
|
|
]
|
|
</code></pre>
|
|
<p><strong>Setting Types:</strong></p>
|
|
<ul>
|
|
<li><code>string</code>: Text input</li>
|
|
<li><code>number</code>: Number input</li>
|
|
<li><code>boolean</code>: On/off toggle</li>
|
|
<li><code>select</code>: Dropdown selection (requires <code>options</code>)</li>
|
|
</ul>
|
|
<p><strong>Setting Fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>key</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Unique setting key (used in code)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>label</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Display name in settings UI</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>type</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td><code>string</code>, <code>number</code>, <code>boolean</code>, or <code>select</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>description</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Help text for the setting</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>required</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>Whether the setting is required</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>secret</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>If true, input will be masked (for passwords/API keys)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>default</code></td>
|
|
<td>any</td>
|
|
<td>No</td>
|
|
<td>Default value</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>options</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>Options for <code>select</code> type</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h3 id="button-setting-type">Button Setting Type</h3>
|
|
<p>The <code>button</code> 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.</p>
|
|
<pre><code class="language-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"
|
|
}
|
|
]
|
|
</code></pre>
|
|
<p><strong>Button-specific fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>action</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Name of the JavaScript function to call</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Implementing button actions in your extension:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<p><strong>Return format for button actions:</strong></p>
|
|
<pre><code class="language-javascript">// Success
|
|
{ success: true, message: "Optional success message" }
|
|
|
|
// Error
|
|
{ success: false, error: "Error description" }
|
|
</code></pre>
|
|
<p><strong>Example with secret field (for API keys/passwords):</strong></p>
|
|
<pre><code class="language-json">"settings": [
|
|
{
|
|
"key": "api_key",
|
|
"label": "API Key",
|
|
"type": "string",
|
|
"description": "Your API key from the service",
|
|
"required": true,
|
|
"secret": true
|
|
}
|
|
]
|
|
</code></pre>
|
|
<h3 id="custom-search-behavior">Custom Search Behavior</h3>
|
|
<p>Extensions can provide custom search functionality (e.g., search YouTube directly):</p>
|
|
<pre><code class="language-json">"searchBehavior": {
|
|
"enabled": true,
|
|
"placeholder": "Search YouTube...",
|
|
"primary": false,
|
|
"icon": "youtube.png",
|
|
"thumbnailRatio": "wide",
|
|
"thumbnailWidth": 100,
|
|
"thumbnailHeight": 56
|
|
}
|
|
</code></pre>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>enabled</code></td>
|
|
<td>boolean</td>
|
|
<td>Whether extension provides custom search</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>placeholder</code></td>
|
|
<td>string</td>
|
|
<td>Placeholder text for search box</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>primary</code></td>
|
|
<td>boolean</td>
|
|
<td>If true, show as primary search tab</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>icon</code></td>
|
|
<td>string</td>
|
|
<td>Icon for search tab</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>thumbnailRatio</code></td>
|
|
<td>string</td>
|
|
<td>Thumbnail aspect ratio preset (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>thumbnailWidth</code></td>
|
|
<td>number</td>
|
|
<td>Custom thumbnail width in pixels (optional)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>thumbnailHeight</code></td>
|
|
<td>number</td>
|
|
<td>Custom thumbnail height in pixels (optional)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h4 id="thumbnail-ratio-presets">Thumbnail Ratio Presets</h4>
|
|
<p>The <code>thumbnailRatio</code> 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.</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Value</th>
|
|
<th>Aspect Ratio</th>
|
|
<th>Use Case</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>"square"</code></td>
|
|
<td>1:1</td>
|
|
<td>Album art, Spotify, Deezer (default)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>"wide"</code></td>
|
|
<td>16:9</td>
|
|
<td>YouTube, video platforms</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>"portrait"</code></td>
|
|
<td>2:3</td>
|
|
<td>Poster-style, vertical thumbnails</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Example for YouTube-style thumbnails:</strong></p>
|
|
<pre><code class="language-json">"searchBehavior": {
|
|
"enabled": true,
|
|
"placeholder": "Search YouTube...",
|
|
"thumbnailRatio": "wide"
|
|
}
|
|
</code></pre>
|
|
<p><strong>Custom dimensions (overrides ratio preset):</strong></p>
|
|
<pre><code class="language-json">"searchBehavior": {
|
|
"enabled": true,
|
|
"thumbnailWidth": 120,
|
|
"thumbnailHeight": 68
|
|
}
|
|
</code></pre>
|
|
<p>When enabled, implement the <code>customSearch</code> function in your extension:</p>
|
|
<pre><code class="language-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)
|
|
}));
|
|
}
|
|
</code></pre>
|
|
<p><strong>Note:</strong> The <code>images</code> field in the returned track objects will be displayed using the <code>thumbnailRatio</code> setting from your manifest. For YouTube-style results, use <code>"thumbnailRatio": "wide"</code> to display 16:9 thumbnails.</p>
|
|
<h3 id="custom-url-handler">Custom URL Handler</h3>
|
|
<p>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.</p>
|
|
<pre><code class="language-json">"urlHandler": {
|
|
"enabled": true,
|
|
"patterns": [
|
|
"music.youtube.com",
|
|
"youtube.com/watch",
|
|
"youtu.be"
|
|
]
|
|
}
|
|
</code></pre>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>enabled</code></td>
|
|
<td>boolean</td>
|
|
<td>Whether extension handles custom URLs</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>patterns</code></td>
|
|
<td>array</td>
|
|
<td>URL patterns to match (domain or path fragments)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Example patterns for common platforms:</strong></p>
|
|
<pre><code class="language-json">// YouTube Music
|
|
"patterns": ["music.youtube.com", "youtube.com/watch", "youtu.be"]
|
|
|
|
// SoundCloud
|
|
"patterns": ["soundcloud.com"]
|
|
|
|
// Bandcamp
|
|
"patterns": ["bandcamp.com"]
|
|
</code></pre>
|
|
<p>When enabled, implement the <code>handleURL</code> function in your extension:</p>
|
|
<pre><code class="language-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"
|
|
}))
|
|
}
|
|
};
|
|
}
|
|
</code></pre>
|
|
<p><strong>Return Types:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
<th>Required Fields</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>track</code></td>
|
|
<td>Single track</td>
|
|
<td><code>track.id</code>, <code>track.name</code>, <code>track.artists</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>album</code></td>
|
|
<td>Album with tracks</td>
|
|
<td><code>album.id</code>, <code>album.name</code>, <code>album.tracks[]</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>artist</code></td>
|
|
<td>Artist with albums</td>
|
|
<td><code>artist.id</code>, <code>artist.name</code>, <code>artist.albums[]</code></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Important:</strong> Don't forget to register the <code>handleURL</code> function:</p>
|
|
<pre><code class="language-javascript">registerExtension({
|
|
initialize: initialize,
|
|
cleanup: cleanup,
|
|
handleURL: handleURL, // Add this!
|
|
// ... other functions
|
|
});
|
|
</code></pre>
|
|
<p><strong>URL Handler Flow:</strong></p>
|
|
<ol>
|
|
<li>User pastes/shares a URL (e.g., <code>https://music.youtube.com/watch?v=abc123</code>)</li>
|
|
<li>SpotiFLAC checks if any extension's <code>patterns</code> match the URL</li>
|
|
<li>If matched, calls the extension's <code>handleURL(url)</code> function</li>
|
|
<li>Extension returns track/album/artist metadata based on <code>type</code> field</li>
|
|
<li>SpotiFLAC navigates to appropriate screen (track detail, album, or artist page)</li>
|
|
</ol>
|
|
<h3 id="album--playlist-functions-v301">Album & Playlist Functions (v3.0.1+)</h3>
|
|
<p>Extensions can provide album/playlist tracks for the search results. When your <code>customSearch</code> returns items with <code>item_type: "album"</code> or <code>item_type: "playlist"</code>, users can tap on them to view the track list.</p>
|
|
<p><strong>Manifest requirements:</strong></p>
|
|
<pre><code class="language-json">{
|
|
"minAppVersion": "3.0.1",
|
|
"type": ["metadata_provider"]
|
|
}
|
|
</code></pre>
|
|
<p><strong>Search result with album/playlist items:</strong></p>
|
|
<pre><code class="language-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
|
|
};
|
|
}
|
|
});
|
|
}
|
|
</code></pre>
|
|
<p><strong>Implement <code>getAlbum</code> and <code>getPlaylist</code> functions:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<p><strong>Return schema for <code>getAlbum</code>:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Album ID</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Album title</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>artists</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Artist name(s)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>cover_url</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Album artwork URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>release_date</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Release date (YYYY or YYYY-MM-DD)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>total_tracks</code></td>
|
|
<td>number</td>
|
|
<td>No</td>
|
|
<td>Number of tracks</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>album_type</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>"album", "ep", "single"</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>tracks</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Array of track objects</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider_id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Your extension ID</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Return schema for <code>getPlaylist</code>:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Playlist ID</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Playlist title</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>owner</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Playlist owner/creator</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>cover_url</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Playlist cover URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>total_tracks</code></td>
|
|
<td>number</td>
|
|
<td>No</td>
|
|
<td>Number of tracks</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>tracks</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Array of track objects</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider_id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Your extension ID</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Flow:</strong></p>
|
|
<ol>
|
|
<li>User searches → <code>customSearch()</code> returns tracks + albums/playlists with <code>item_type</code></li>
|
|
<li>Search results show mixed items (tracks show duration, albums show "Album • Artist • Year")</li>
|
|
<li>User taps album/playlist → SpotiFLAC calls <code>getAlbum(id)</code> or <code>getPlaylist(id)</code></li>
|
|
<li>Extension fetches and returns track list</li>
|
|
<li>SpotiFLAC displays tracks, user can download them</li>
|
|
</ol>
|
|
<h3 id="artist-support">Artist Support</h3>
|
|
<p>Extensions can support artist pages by returning artist items from <code>customSearch()</code> and implementing <code>getArtist()</code>:</p>
|
|
<p><strong>Return artist items from customSearch:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
}
|
|
</code></pre>
|
|
<p><strong>Implement <code>getArtist</code> function:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<p><strong>Return schema for <code>getArtist</code>:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Artist ID</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Artist name</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>image_url</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Artist image URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>albums</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Array of album objects (see album schema)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider_id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Your extension ID</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Album object schema (within <code>albums</code> array):</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Album ID</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Album title</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>artists</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Artist name(s)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>cover_url</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Album artwork URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>release_date</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Release date (YYYY or YYYY-MM-DD)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>total_tracks</code></td>
|
|
<td>number</td>
|
|
<td>No</td>
|
|
<td>Number of tracks</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>album_type</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>"album", "ep", "single", "compilation"</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider_id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Your extension ID</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h3 id="home-feed-support">Home Feed Support</h3>
|
|
<p>Extensions can provide a personalized home feed with sections containing tracks, albums, playlists, and artists. This is useful for music streaming service extensions that have personalized recommendations.</p>
|
|
<p><strong>Manifest configuration:</strong></p>
|
|
<pre><code class="language-json">{
|
|
"name": "my-music-extension",
|
|
"version": "1.0.0",
|
|
"capabilities": {
|
|
"homeFeed": true,
|
|
"browseCategories": true
|
|
}
|
|
}
|
|
</code></pre>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Capability</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>homeFeed</code></td>
|
|
<td>Extension provides personalized home feed via <code>getHomeFeed()</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>browseCategories</code></td>
|
|
<td>Extension provides browse categories via <code>getBrowseCategories()</code></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Implement <code>getHomeFeed</code> function:</strong></p>
|
|
<pre><code class="language-javascript">/**
|
|
* Fetch personalized home feed with sections
|
|
* @returns {Object} Home feed data with greeting and sections
|
|
*/
|
|
function getHomeFeed() {
|
|
// Fetch home data from your API
|
|
const response = http.get("https://api.example.com/home", {
|
|
headers: { "Authorization": "Bearer " + accessToken }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return { success: false, error: "Failed to fetch home feed" };
|
|
}
|
|
|
|
const data = JSON.parse(response.body);
|
|
|
|
return {
|
|
success: true,
|
|
greeting: data.greeting || "Good morning", // Time-based greeting
|
|
sections: data.sections.map(section => ({
|
|
uri: section.id,
|
|
title: section.title,
|
|
items: section.items.map(item => formatHomeFeedItem(item))
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format a single item for home feed
|
|
*/
|
|
function formatHomeFeedItem(item) {
|
|
const result = {
|
|
id: item.id,
|
|
uri: item.uri, // e.g., "myservice:track:abc123"
|
|
type: item.type, // "track", "album", "playlist", "artist"
|
|
name: item.name,
|
|
artists: item.artistName || "", // Artist name(s) for tracks/albums
|
|
description: item.description, // For playlists
|
|
cover_url: item.imageUrl,
|
|
provider_id: "my-music-extension"
|
|
};
|
|
|
|
// For tracks, include album info for "Go to Album" feature
|
|
if (item.type === "track" && item.album) {
|
|
result.album_id = item.album.id;
|
|
result.album_name = item.album.name;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Register function
|
|
registerExtension({
|
|
// ... other functions
|
|
getHomeFeed: getHomeFeed
|
|
});
|
|
</code></pre>
|
|
<p><strong>Return schema for <code>getHomeFeed</code>:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>success</code></td>
|
|
<td>boolean</td>
|
|
<td>Yes</td>
|
|
<td>Whether the request succeeded</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>error</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Error message if success is false</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>greeting</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Time-based greeting (e.g., "Good morning")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>sections</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Array of section objects</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Section object schema:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>uri</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Section identifier/URI</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>title</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Section title (e.g., "Trending Songs", "Popular Artists")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>items</code></td>
|
|
<td>array</td>
|
|
<td>Yes</td>
|
|
<td>Array of item objects</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Item object schema:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Item ID (track/album/playlist/artist ID)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>uri</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Full URI (e.g., "spotify:track:abc123")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>type</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Item type: "track", "album", "playlist", "artist", "station"</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>name</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Item name/title</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>artists</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Artist name(s) - for tracks and albums</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>description</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Description - for playlists</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>cover_url</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Cover/artwork image URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>album_id</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Album ID - for tracks (enables "Go to Album")</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>album_name</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Album name - for tracks</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider_id</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td>Your extension ID</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Getting timezone for time-based greeting:</strong></p>
|
|
<blockquote>
|
|
<p><strong>Note:</strong> The Goja JavaScript engine may not support <code>Intl.DateTimeFormat()</code> properly and <code>Date.getTimezoneOffset()</code> may return <code>0</code>. Use <code>gobackend.getLocalTime()</code> for accurate timezone detection. See <a href="#go-backend-api">Go Backend API</a> for details.</p>
|
|
</blockquote>
|
|
<pre><code class="language-javascript">function getHomeFeed() {
|
|
// Get user's timezone using gobackend API (recommended)
|
|
let timeZone = "UTC";
|
|
try {
|
|
const localTime = gobackend.getLocalTime();
|
|
if (localTime.timezone && localTime.timezone !== "Local") {
|
|
timeZone = localTime.timezone;
|
|
}
|
|
} catch (e) {
|
|
// Fallback to UTC
|
|
}
|
|
|
|
// Use timezone in API request for proper greeting
|
|
const response = http.get("https://api.example.com/home?timezone=" + encodeURIComponent(timeZone));
|
|
// ...
|
|
}
|
|
|
|
// For time-based greeting, use local hour directly
|
|
function getTimeBasedGreeting() {
|
|
const localTime = gobackend.getLocalTime();
|
|
const hour = localTime.hour;
|
|
|
|
if (hour >= 5 && hour < 12) return "Good morning";
|
|
if (hour >= 12 && hour < 17) return "Good afternoon";
|
|
if (hour >= 17 && hour < 21) return "Good evening";
|
|
return "Good night";
|
|
}
|
|
</code></pre>
|
|
<p><strong>Implement <code>getBrowseCategories</code> function (optional):</strong></p>
|
|
<pre><code class="language-javascript">/**
|
|
* Fetch browse categories (genres, moods, etc.)
|
|
* @returns {Object} Categories data
|
|
*/
|
|
function getBrowseCategories() {
|
|
const response = http.get("https://api.example.com/browse/categories");
|
|
|
|
if (!response.ok) {
|
|
return { success: false, error: "Failed to fetch categories" };
|
|
}
|
|
|
|
const data = JSON.parse(response.body);
|
|
|
|
return {
|
|
success: true,
|
|
categories: data.categories.map(cat => ({
|
|
id: cat.id,
|
|
name: cat.name,
|
|
icon_url: cat.imageUrl
|
|
}))
|
|
};
|
|
}
|
|
|
|
registerExtension({
|
|
// ... other functions
|
|
getHomeFeed: getHomeFeed,
|
|
getBrowseCategories: getBrowseCategories
|
|
});
|
|
</code></pre>
|
|
<p><strong>UI Behavior:</strong></p>
|
|
<p>When an extension has <code>homeFeed</code> capability enabled:</p>
|
|
<ol>
|
|
<li>The home screen shows personalized sections instead of the default placeholder</li>
|
|
<li>Each section displays horizontally scrollable items (tracks, albums, etc.)</li>
|
|
<li>Tapping items navigates to appropriate screens:
|
|
<ul>
|
|
<li><strong>Track</strong>: Shows bottom sheet with "Download" and "Go to Album" options</li>
|
|
<li><strong>Album</strong>: Opens album screen with track list</li>
|
|
<li><strong>Playlist</strong>: Opens playlist screen with track list</li>
|
|
<li><strong>Artist</strong>: Opens artist screen with discography</li>
|
|
</ul>
|
|
</li>
|
|
<li>Pull-to-refresh reloads the home feed</li>
|
|
<li>Home feed is cached for 5 minutes to reduce API calls</li>
|
|
</ol>
|
|
<h3 id="track-enrichment">Track Enrichment</h3>
|
|
<p>Extensions can enrich track metadata before download using <code>enrichTrack()</code>. This is useful for:</p>
|
|
<ul>
|
|
<li>Adding ISRC codes from external APIs (e.g., Odesli/song.link)</li>
|
|
<li>Getting links to other streaming services for fallback downloads</li>
|
|
<li>Enriching metadata with additional info</li>
|
|
</ul>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<p><strong>Enriched track fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>isrc</code></td>
|
|
<td>string</td>
|
|
<td>International Standard Recording Code</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>tidal_id</code></td>
|
|
<td>string</td>
|
|
<td>Tidal track ID for direct download (skip search)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>qobuz_id</code></td>
|
|
<td>string</td>
|
|
<td>Qobuz track ID for direct download (skip search)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>deezer_id</code></td>
|
|
<td>string</td>
|
|
<td>Deezer track ID for fallback</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>spotify_id</code></td>
|
|
<td>string</td>
|
|
<td>Spotify track ID for fallback</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links</code></td>
|
|
<td>object</td>
|
|
<td>Map of service → URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links.tidal</code></td>
|
|
<td>string</td>
|
|
<td>Tidal track URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links.qobuz</code></td>
|
|
<td>string</td>
|
|
<td>Qobuz track URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links.deezer</code></td>
|
|
<td>string</td>
|
|
<td>Deezer track URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links.spotify</code></td>
|
|
<td>string</td>
|
|
<td>Spotify track URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>external_links.apple</code></td>
|
|
<td>string</td>
|
|
<td>Apple Music track URL</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>How enrichment enables high-quality downloads:</strong></p>
|
|
<p>When your extension provides <code>tidal_id</code> or <code>qobuz_id</code>, SpotiFLAC can download lossless audio <strong>without searching</strong>. This is the recommended approach for extensions that don't provide their own audio source.</p>
|
|
<pre><code>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!)
|
|
</code></pre>
|
|
<p><strong>Important:</strong> This enrichment flow <strong>only applies to extension tracks</strong>. Normal Spotify/Deezer downloads are not affected and continue using their standard flow.</p>
|
|
<p><strong>Complete enrichTrack example with all service IDs:</strong></p>
|
|
<pre><code class="language-javascript">function enrichTrack(track) {
|
|
if (!track || !track.id) return track;
|
|
|
|
// Build URL for Odesli lookup (adjust for your service)
|
|
const sourceUrl = "https://your-service.com/track/" + track.id;
|
|
const odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(sourceUrl);
|
|
|
|
try {
|
|
const res = fetch(odesliUrl, { method: "GET" });
|
|
if (!res || !res.ok) return track;
|
|
|
|
const data = res.json();
|
|
const enrichment = { external_links: {} };
|
|
|
|
// Extract ISRC (used for search fallback)
|
|
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 service-specific IDs for DIRECT download (no search!)
|
|
if (data.linksByPlatform) {
|
|
const links = data.linksByPlatform;
|
|
|
|
// Tidal - enables lossless/MQA download
|
|
if (links.tidal && links.tidal.url) {
|
|
enrichment.external_links.tidal = links.tidal.url;
|
|
const match = links.tidal.url.match(/\/track\/(\d+)/);
|
|
if (match) enrichment.tidal_id = match[1];
|
|
}
|
|
|
|
// Qobuz - enables Hi-Res FLAC download
|
|
if (links.qobuz && links.qobuz.url) {
|
|
enrichment.external_links.qobuz = links.qobuz.url;
|
|
const match = links.qobuz.url.match(/\/track\/(\d+)/);
|
|
if (match) enrichment.qobuz_id = match[1];
|
|
}
|
|
|
|
// Deezer - enables FLAC download
|
|
if (links.deezer && links.deezer.url) {
|
|
enrichment.external_links.deezer = links.deezer.url;
|
|
const match = links.deezer.url.match(/\/track\/(\d+)/);
|
|
if (match) enrichment.deezer_id = match[1];
|
|
}
|
|
|
|
// Spotify - for metadata/display
|
|
if (links.spotify && links.spotify.url) {
|
|
enrichment.external_links.spotify = links.spotify.url;
|
|
const match = links.spotify.url.match(/\/track\/([a-zA-Z0-9]+)/);
|
|
if (match) enrichment.spotify_id = match[1];
|
|
}
|
|
}
|
|
|
|
return Object.assign({}, track, enrichment);
|
|
} catch (e) {
|
|
log.error("enrichTrack failed", e);
|
|
return track;
|
|
}
|
|
}
|
|
</code></pre>
|
|
<p><strong>Download priority with enrichment:</strong></p>
|
|
<ol>
|
|
<li><strong><code>tidal_id</code></strong> → Direct Tidal download (highest priority, lossless/MQA)</li>
|
|
<li><strong><code>qobuz_id</code></strong> → Direct Qobuz download (Hi-Res FLAC up to 24-bit/192kHz)</li>
|
|
<li><strong>ISRC search</strong> → Search Tidal/Qobuz by ISRC code</li>
|
|
<li><strong>Metadata search</strong> → Search by track name/artist (last resort)</li>
|
|
</ol>
|
|
<h3 id="custom-track-matching">Custom Track Matching</h3>
|
|
<p>Extensions can override the default ISRC-based track matching:</p>
|
|
<pre><code class="language-json">"trackMatching": {
|
|
"customMatching": true,
|
|
"strategy": "custom",
|
|
"durationTolerance": 5
|
|
}
|
|
</code></pre>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>customMatching</code></td>
|
|
<td>boolean</td>
|
|
<td>Whether extension handles matching</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>strategy</code></td>
|
|
<td>string</td>
|
|
<td>"isrc", "name", "duration", or "custom"</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>durationTolerance</code></td>
|
|
<td>number</td>
|
|
<td>Tolerance in seconds for duration matching</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p>When enabled, implement the <code>matchTrack</code> function:</p>
|
|
<pre><code class="language-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" };
|
|
}
|
|
</code></pre>
|
|
<h3 id="post-processing-hooks">Post-Processing Hooks</h3>
|
|
<p>Extensions can modify files after download (convert format, normalize audio, etc.):</p>
|
|
<pre><code class="language-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"]
|
|
}
|
|
]
|
|
}
|
|
</code></pre>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>enabled</code></td>
|
|
<td>boolean</td>
|
|
<td>Whether extension provides post-processing</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks</code></td>
|
|
<td>array</td>
|
|
<td>List of available hooks</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks[].id</code></td>
|
|
<td>string</td>
|
|
<td>Unique hook identifier</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks[].name</code></td>
|
|
<td>string</td>
|
|
<td>Display name</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks[].description</code></td>
|
|
<td>string</td>
|
|
<td>Description</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks[].defaultEnabled</code></td>
|
|
<td>boolean</td>
|
|
<td>Whether enabled by default</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>hooks[].supportedFormats</code></td>
|
|
<td>array</td>
|
|
<td>Supported file formats</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p>Implement the <code>postProcess</code> function:</p>
|
|
<pre><code class="language-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 };
|
|
}
|
|
</code></pre>
|
|
<h4 id="post-process-api-v2-recommended">Post-Process API v2 (Recommended)</h4>
|
|
<p>For SAF/scoped storage support, extensions can implement <code>postProcessV2</code> (preferred).
|
|
If present, it will be called before <code>postProcess</code>.</p>
|
|
<pre><code class="language-javascript">function postProcessV2(input, metadata, hookId) {
|
|
// input: {
|
|
// path: string (temp/local path, if available)
|
|
// uri: string (content:// URI, if available)
|
|
// name: string (file name)
|
|
// mime_type: string
|
|
// size: number (bytes)
|
|
// is_saf: boolean
|
|
// }
|
|
const filePath = input.path || "";
|
|
if (!filePath) {
|
|
return { success: false, error: "no path provided" };
|
|
}
|
|
return postProcess(filePath, metadata, hookId);
|
|
}
|
|
</code></pre>
|
|
<p>Notes:</p>
|
|
<ul>
|
|
<li><code>postProcessV2</code> is designed for SAF (Android 10+) and content URIs.</li>
|
|
<li><code>postProcess</code> (v1) remains supported but will be deprecated in a future release.</li>
|
|
</ul>
|
|
<h3 id="lyrics-provider">Lyrics Provider</h3>
|
|
<p>Extensions can provide lyrics for tracks by declaring <code>"lyrics_provider"</code> in the <code>type</code> array. Lyrics provider extensions are called <strong>before</strong> built-in providers (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music), giving extensions the highest priority in the lyrics cascade.</p>
|
|
<p><strong>Manifest configuration:</strong></p>
|
|
<pre><code class="language-json">{
|
|
"name": "my-lyrics-source",
|
|
"displayName": "My Lyrics Source",
|
|
"version": "1.0.0",
|
|
"description": "Fetch lyrics from My Lyrics API",
|
|
"author": "Developer",
|
|
"type": ["lyrics_provider"],
|
|
"permissions": {
|
|
"network": ["api.my-lyrics.com"],
|
|
"storage": true
|
|
}
|
|
}
|
|
</code></pre>
|
|
<p><strong>Implement the <code>fetchLyrics</code> function:</strong></p>
|
|
<pre><code class="language-javascript">/**
|
|
* Fetch lyrics for a track
|
|
* @param {string} trackName - Track title
|
|
* @param {string} artistName - Artist name
|
|
* @param {string} albumName - Album name (may be empty)
|
|
* @param {number} durationSec - Track duration in seconds
|
|
* @returns {Object|null} Lyrics result object, or null if not found
|
|
*/
|
|
function fetchLyrics(trackName, artistName, albumName, durationSec) {
|
|
var query = encodeURIComponent(trackName + " " + artistName);
|
|
var resp = http.get("https://api.my-lyrics.com/search?q=" + query, {});
|
|
|
|
if (!resp.ok) {
|
|
return null; // Return null to let the next provider try
|
|
}
|
|
|
|
var data = JSON.parse(resp.body);
|
|
if (!data || !data.lyrics) {
|
|
return null;
|
|
}
|
|
|
|
// For synced lyrics (LRC / timed lines)
|
|
if (data.syncedLines) {
|
|
var lines = [];
|
|
for (var i = 0; i < data.syncedLines.length; i++) {
|
|
var line = data.syncedLines[i];
|
|
lines.push({
|
|
startTimeMs: line.startMs,
|
|
words: line.text,
|
|
endTimeMs: line.endMs || 0
|
|
});
|
|
}
|
|
return {
|
|
lines: lines,
|
|
syncType: "LINE_SYNCED",
|
|
instrumental: false,
|
|
plainLyrics: "",
|
|
provider: "My Lyrics Source"
|
|
};
|
|
}
|
|
|
|
// For plain/unsynced lyrics
|
|
return {
|
|
lines: [],
|
|
syncType: "UNSYNCED",
|
|
instrumental: false,
|
|
plainLyrics: data.lyrics,
|
|
provider: "My Lyrics Source"
|
|
};
|
|
}
|
|
|
|
// Register extension
|
|
registerExtension({
|
|
initialize: function(config) { return true; },
|
|
cleanup: function() {},
|
|
fetchLyrics: fetchLyrics
|
|
});
|
|
</code></pre>
|
|
<p><strong>Return schema for <code>fetchLyrics</code>:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Required</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>lines</code></td>
|
|
<td>array</td>
|
|
<td>No</td>
|
|
<td>Array of synced lyric line objects (see below)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>syncType</code></td>
|
|
<td>string</td>
|
|
<td>Yes</td>
|
|
<td><code>"LINE_SYNCED"</code>, <code>"WORD_SYNCED"</code>, or <code>"UNSYNCED"</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>instrumental</code></td>
|
|
<td>boolean</td>
|
|
<td>No</td>
|
|
<td>If <code>true</code>, track is instrumental (no lyrics)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>plainLyrics</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Plain text lyrics (fallback when no synced lines)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>provider</code></td>
|
|
<td>string</td>
|
|
<td>No</td>
|
|
<td>Provider name for attribution (defaults to extension <code>displayName</code>)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Lyrics line object:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>startTimeMs</code></td>
|
|
<td>number</td>
|
|
<td>Start time in milliseconds</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>words</code></td>
|
|
<td>string</td>
|
|
<td>The lyric text for this line</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>endTimeMs</code></td>
|
|
<td>number</td>
|
|
<td>End time in milliseconds (optional, 0 if unknown)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Sync types explained:</strong></p>
|
|
<ul>
|
|
<li><code>LINE_SYNCED</code> — Each line has a start timestamp (standard LRC format). Most common for karaoke-style lyrics.</li>
|
|
<li><code>WORD_SYNCED</code> — Each word has individual timestamps. Used by Apple Music and some premium services.</li>
|
|
<li><code>UNSYNCED</code> — Plain text without timestamps. Used as a last resort fallback.</li>
|
|
</ul>
|
|
<p><strong>Returning <code>null</code> vs empty result:</strong></p>
|
|
<ul>
|
|
<li>Return <code>null</code> to signal “not found” — the next provider in the cascade will be tried.</li>
|
|
<li>Return an object with <code>instrumental: true</code> to indicate an instrumental track (stops the cascade).</li>
|
|
<li>Return an object with <code>plainLyrics</code> as a fallback when synced lines are unavailable — the runtime will auto-split plain text into unsynced lines.</li>
|
|
</ul>
|
|
<p><strong>Lyrics provider cascade order:</strong></p>
|
|
<ol>
|
|
<li><strong>Extension lyrics providers</strong> (highest priority, tried first)</li>
|
|
<li><strong>Built-in providers</strong> in user-configured order (default: LRCLIB → Musixmatch → Netease → Apple Music → QQ Music)</li>
|
|
</ol>
|
|
<blockquote>
|
|
<strong>Note:</strong> If multiple lyrics extensions are installed, they are sorted alphabetically by extension ID and tried in that order.
|
|
</blockquote>
|
|
<hr />
|
|
<h2 id="main-script">Main Script</h2>
|
|
<p>The <code>main.js</code> file (or <code>index.js</code>) contains the extension's JavaScript code.</p>
|
|
<h3 id="basic-structure">Basic Structure</h3>
|
|
<pre><code class="language-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!");
|
|
</code></pre>
|
|
<h3 id="important-registerextension">Important: registerExtension()</h3>
|
|
<p><strong>Every extension MUST call <code>registerExtension()</code> at the end of the script.</strong> 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()".</p>
|
|
<pre><code class="language-javascript">// Minimal example
|
|
registerExtension({
|
|
initialize: function(config) { return true; },
|
|
cleanup: function() {},
|
|
searchTracks: function(query, limit) { return []; }
|
|
});
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="api-reference">API Reference</h2>
|
|
<h3 id="http-api">HTTP API</h3>
|
|
<p>The HTTP API provides full control over network requests with automatic cookie management.</p>
|
|
<pre><code class="language-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();
|
|
</code></pre>
|
|
<h4 id="request-headers">Request Headers</h4>
|
|
<p>Headers are optional. If you provide a custom <code>User-Agent</code>, it will be used instead of the default.</p>
|
|
<pre><code class="language-javascript">// Custom User-Agent is respected
|
|
const response = http.get(url, {
|
|
"User-Agent": "MyExtension/1.0",
|
|
"Authorization": "Bearer token"
|
|
});
|
|
</code></pre>
|
|
<h4 id="response-object">Response Object</h4>
|
|
<pre><code class="language-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);
|
|
</code></pre>
|
|
<h4 id="form-encoded-post-applicationx-www-form-urlencoded">Form-Encoded POST (application/x-www-form-urlencoded)</h4>
|
|
<p>For OAuth token exchanges and APIs that require form-encoded data:</p>
|
|
<pre><code class="language-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"
|
|
});
|
|
</code></pre>
|
|
<p><strong>Important:</strong> When using form-encoded POST, you MUST set the <code>Content-Type</code> header to <code>application/x-www-form-urlencoded</code>. Otherwise, the default <code>application/json</code> will be used.</p>
|
|
<h4 id="cookie-jar">Cookie Jar</h4>
|
|
<p>Each extension has its own persistent cookie jar. Cookies are automatically:</p>
|
|
<ul>
|
|
<li>Stored when received via <code>Set-Cookie</code> headers</li>
|
|
<li>Sent with subsequent requests to the same domain</li>
|
|
</ul>
|
|
<pre><code class="language-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();
|
|
</code></pre>
|
|
<h4 id="youtube-music--innertube-api-example">YouTube Music / Innertube API Example</h4>
|
|
<p>For YouTube Music extensions, you need to declare all required domains in your manifest:</p>
|
|
<pre><code class="language-json">{
|
|
"permissions": {
|
|
"network": [
|
|
"music.youtube.com",
|
|
"www.youtube.com",
|
|
"youtube.com",
|
|
"youtubei.googleapis.com",
|
|
"*.googlevideo.com",
|
|
"*.youtube.com",
|
|
"*.ytimg.com"
|
|
],
|
|
"storage": true
|
|
}
|
|
}
|
|
</code></pre>
|
|
<p>Example Innertube API call:</p>
|
|
<pre><code class="language-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);
|
|
}
|
|
</code></pre>
|
|
<h3 id="browser-like-polyfills">Browser-like Polyfills</h3>
|
|
<p>SpotiFLAC provides browser-compatible APIs to make porting web libraries easier. These polyfills work within the sandbox security model.</p>
|
|
<h4 id="fetch-api">fetch() API</h4>
|
|
<p>The global <code>fetch()</code> function provides a browser-compatible HTTP API:</p>
|
|
<pre><code class="language-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);
|
|
}
|
|
</code></pre>
|
|
<p><strong>Response Object:</strong></p>
|
|
<pre><code class="language-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
|
|
}
|
|
</code></pre>
|
|
<p><strong>Note:</strong> Unlike browser fetch, this is synchronous (not Promise-based) due to Goja VM limitations. However, the API signature is compatible for easier porting.</p>
|
|
<h4 id="atob--btoa">atob() / btoa()</h4>
|
|
<p>Global Base64 encoding/decoding functions:</p>
|
|
<pre><code class="language-javascript">// Encode string to Base64
|
|
const encoded = btoa("Hello, World!"); // "SGVsbG8sIFdvcmxkIQ=="
|
|
|
|
// Decode Base64 to string
|
|
const decoded = atob("SGVsbG8sIFdvcmxkIQ=="); // "Hello, World!"
|
|
</code></pre>
|
|
<h4 id="textencoder--textdecoder">TextEncoder / TextDecoder</h4>
|
|
<p>For encoding/decoding text to/from byte arrays:</p>
|
|
<pre><code class="language-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"
|
|
</code></pre>
|
|
<h4 id="url--urlsearchparams">URL / URLSearchParams</h4>
|
|
<p>For URL parsing and manipulation:</p>
|
|
<pre><code class="language-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"
|
|
</code></pre>
|
|
<h4 id="porting-browser-libraries">Porting Browser Libraries</h4>
|
|
<p>When porting browser libraries (like <code>youtubei.js</code>), you may need to:</p>
|
|
<ol>
|
|
<li><strong>Bundle the library</strong> using Webpack, Rollup, or Esbuild to create a single file</li>
|
|
<li><strong>Replace unsupported APIs</strong> with SpotiFLAC equivalents:
|
|
<ul>
|
|
<li><code>fetch()</code> → Already supported (synchronous version)</li>
|
|
<li><code>localStorage</code> → Use <code>storage.get/set</code></li>
|
|
<li><code>crypto.subtle</code> → Use <code>utils.md5/sha256</code> or <code>credentials</code> API</li>
|
|
</ul>
|
|
</li>
|
|
<li><strong>Declare all domains</strong> in manifest permissions</li>
|
|
</ol>
|
|
<p>Example bundler config (Rollup):</p>
|
|
<pre><code class="language-javascript">// rollup.config.js
|
|
export default {
|
|
input: 'src/index.js',
|
|
output: {
|
|
file: 'dist/index.js',
|
|
format: 'iife', // Immediately Invoked Function Expression
|
|
name: 'MyExtension'
|
|
}
|
|
};
|
|
</code></pre>
|
|
<h3 id="storage-api">Storage API</h3>
|
|
<pre><code class="language-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");
|
|
</code></pre>
|
|
<h3 id="file-api">File API</h3>
|
|
<pre><code class="language-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);
|
|
</code></pre>
|
|
<p><strong>Note:</strong> File operations are limited to the extension's data directory.</p>
|
|
<h3 id="logging-api">Logging API</h3>
|
|
<pre><code class="language-javascript">log.debug("Debug message", data);
|
|
log.info("Info message", data);
|
|
log.warn("Warning message", data);
|
|
log.error("Error message", data);
|
|
</code></pre>
|
|
<h3 id="utility-api">Utility API</h3>
|
|
<pre><code class="language-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)
|
|
</code></pre>
|
|
<h4 id="hmac-sha1-for-totp">HMAC-SHA1 for TOTP</h4>
|
|
<p><code>utils.hmacSHA1</code> is useful for implementing TOTP (Time-based One-Time Password) authentication:</p>
|
|
<pre><code class="language-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);
|
|
</code></pre>
|
|
<h4 id="hmac-sha256-example-api-signing">HMAC-SHA256 Example (API Signing)</h4>
|
|
<p>Many APIs require request signing using HMAC-SHA256. Here's a complete example:</p>
|
|
<pre><code class="language-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
|
|
});
|
|
}
|
|
</code></pre>
|
|
<h3 id="go-backend-api">Go Backend API</h3>
|
|
<pre><code class="language-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, ... }
|
|
|
|
// Get device local time (accurate timezone detection)
|
|
const localTime = gobackend.getLocalTime();
|
|
// returns object:
|
|
// {
|
|
// year: 2026,
|
|
// month: 1, // 1-12
|
|
// day: 22, // 1-31
|
|
// hour: 14, // 0-23 (local time)
|
|
// minute: 30, // 0-59
|
|
// second: 45, // 0-59
|
|
// weekday: 4, // 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
// offsetMinutes: -420, // Timezone offset (JS convention: negative for east of UTC)
|
|
// timezone: "Asia/Jakarta", // Go timezone string
|
|
// timestamp: 1769140245 // Unix timestamp
|
|
// }
|
|
</code></pre>
|
|
<h4 id="using-getlocaltime-for-time-based-greeting">Using <code>getLocalTime()</code> for Time-Based Greeting</h4>
|
|
<p>The Goja JavaScript engine may return <code>0</code> for <code>Date.getTimezoneOffset()</code>, making it unreliable for timezone detection. Use <code>gobackend.getLocalTime()</code> instead:</p>
|
|
<pre><code class="language-javascript">function getTimeBasedGreeting() {
|
|
// Use gobackend.getLocalTime() for accurate device time
|
|
var localTime = gobackend.getLocalTime();
|
|
var hour = localTime.hour;
|
|
|
|
if (hour >= 5 && hour < 12) {
|
|
return "Good morning";
|
|
} else if (hour >= 12 && hour < 17) {
|
|
return "Good afternoon";
|
|
} else if (hour >= 17 && hour < 21) {
|
|
return "Good evening";
|
|
} else {
|
|
return "Good night";
|
|
}
|
|
}
|
|
</code></pre>
|
|
<h4 id="using-getlocaltime-for-timezone-in-api-calls">Using <code>getLocalTime()</code> for Timezone in API Calls</h4>
|
|
<pre><code class="language-javascript">function fetchHomeFeed() {
|
|
// Get timezone for API request
|
|
let timeZone = "UTC";
|
|
try {
|
|
const localTime = gobackend.getLocalTime();
|
|
if (localTime.timezone && localTime.timezone !== "Local") {
|
|
timeZone = localTime.timezone;
|
|
} else {
|
|
// Map offset to timezone string if needed
|
|
const offsetMinutes = localTime.offsetMinutes;
|
|
const tzMap = {
|
|
'-420': 'Asia/Jakarta', // UTC+7
|
|
'-480': 'Asia/Singapore', // UTC+8
|
|
'-540': 'Asia/Tokyo', // UTC+9
|
|
'0': 'Europe/London', // UTC+0
|
|
'300': 'America/New_York', // UTC-5
|
|
'480': 'America/Los_Angeles' // UTC-8
|
|
};
|
|
timeZone = tzMap[String(offsetMinutes)] || "UTC";
|
|
}
|
|
} catch (e) {
|
|
// Fallback to UTC
|
|
}
|
|
|
|
// Use timezone in API request
|
|
const response = http.get("https://api.example.com/home?timezone=" + encodeURIComponent(timeZone));
|
|
// ...
|
|
}
|
|
</code></pre>
|
|
<h3 id="credentials-api-encrypted">Credentials API (Encrypted)</h3>
|
|
<pre><code class="language-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");
|
|
</code></pre>
|
|
<h3 id="auth-api-oauth-support">Auth API (OAuth Support)</h3>
|
|
<pre><code class="language-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();
|
|
</code></pre>
|
|
<h3 id="pkce-oauth-flow-recommended">PKCE OAuth Flow (Recommended)</h3>
|
|
<p>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.</p>
|
|
<h4 id="quick-start-high-level-api">Quick Start (High-Level API)</h4>
|
|
<pre><code class="language-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
|
|
}
|
|
</code></pre>
|
|
<h4 id="low-level-api-manual-control">Low-Level API (Manual Control)</h4>
|
|
<pre><code class="language-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" }
|
|
);
|
|
</code></pre>
|
|
<h4 id="pkce-api-reference">PKCE API Reference</h4>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Function</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>auth.generatePKCE(length?)</code></td>
|
|
<td>Generate PKCE verifier/challenge pair (stored automatically)</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>auth.getPKCE()</code></td>
|
|
<td>Get stored PKCE pair</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>auth.startOAuthWithPKCE(config)</code></td>
|
|
<td>High-level: generate PKCE + open OAuth URL</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>auth.exchangeCodeWithPKCE(config)</code></td>
|
|
<td>High-level: exchange code using stored verifier</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>startOAuthWithPKCE config:</strong></p>
|
|
<pre><code class="language-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
|
|
}
|
|
</code></pre>
|
|
<p><strong>exchangeCodeWithPKCE config:</strong></p>
|
|
<pre><code class="language-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
|
|
}
|
|
</code></pre>
|
|
<h4 id="complete-oauth-example">Complete OAuth Example</h4>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<h3 id="crypto-utilities">Crypto Utilities</h3>
|
|
<pre><code class="language-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" }
|
|
</code></pre>
|
|
<h3 id="ffmpeg-api-post-processing">FFmpeg API (Post-Processing)</h3>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<h3 id="track-matching-api">Track Matching API</h3>
|
|
<pre><code class="language-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"
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="extension-examples">Extension Examples</h2>
|
|
<h3 id="example-1-simple-metadata-provider">Example 1: Simple Metadata Provider</h3>
|
|
<p>Extension that provides search from a public API.</p>
|
|
<p><strong>manifest.json:</strong></p>
|
|
<pre><code class="language-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": []
|
|
}
|
|
</code></pre>
|
|
<p><strong>main.js:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<h3 id="example-2-download-provider-with-auth">Example 2: Download Provider with Auth</h3>
|
|
<p>Extension that provides downloads with authentication.</p>
|
|
<p><strong>manifest.json:</strong></p>
|
|
<pre><code class="language-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
|
|
}
|
|
]
|
|
}
|
|
</code></pre>
|
|
<p><strong>main.js:</strong></p>
|
|
<pre><code class="language-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!");
|
|
</code></pre>
|
|
<h3 id="example-3-lyrics-provider">Example 3: Lyrics Provider</h3>
|
|
<p>Extension that fetches synced lyrics from a public lyrics API.</p>
|
|
<p><strong>manifest.json:</strong></p>
|
|
<pre><code class="language-json">{
|
|
"name": "open-lyrics",
|
|
"displayName": "Open Lyrics",
|
|
"version": "1.0.0",
|
|
"description": "Fetch synced and plain lyrics from Open Lyrics API",
|
|
"author": "Developer",
|
|
"permissions": {
|
|
"network": ["api.open-lyrics.com"]
|
|
},
|
|
"type": ["lyrics_provider"]
|
|
}
|
|
</code></pre>
|
|
<p><strong>index.js:</strong></p>
|
|
<pre><code class="language-javascript">function initialize(config) {
|
|
log.info("Open Lyrics extension initialized");
|
|
return true;
|
|
}
|
|
|
|
function cleanup() {}
|
|
|
|
/**
|
|
* Fetch lyrics for a track.
|
|
* Called by the runtime with track metadata.
|
|
* Return null if no lyrics are found (next provider will be tried).
|
|
*/
|
|
function fetchLyrics(trackName, artistName, albumName, durationSec) {
|
|
// Search for the track
|
|
var query = encodeURIComponent(trackName + " " + artistName);
|
|
var resp = http.get("https://api.open-lyrics.com/v1/search?q=" + query + "&duration=" + Math.round(durationSec), {});
|
|
|
|
if (!resp.ok) {
|
|
log.warn("Search failed with status " + resp.statusCode);
|
|
return null;
|
|
}
|
|
|
|
var data = JSON.parse(resp.body);
|
|
if (!data.results || data.results.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
var trackId = data.results[0].id;
|
|
|
|
// Fetch lyrics by ID
|
|
var lyricsResp = http.get("https://api.open-lyrics.com/v1/lyrics/" + trackId, {});
|
|
if (!lyricsResp.ok) {
|
|
return null;
|
|
}
|
|
|
|
var lyricsData = JSON.parse(lyricsResp.body);
|
|
|
|
// Instrumental track
|
|
if (lyricsData.instrumental) {
|
|
return {
|
|
lines: [],
|
|
syncType: "LINE_SYNCED",
|
|
instrumental: true,
|
|
plainLyrics: "",
|
|
provider: "Open Lyrics"
|
|
};
|
|
}
|
|
|
|
// Synced lyrics available
|
|
if (lyricsData.syncedLines && lyricsData.syncedLines.length > 0) {
|
|
var lines = [];
|
|
for (var i = 0; i < lyricsData.syncedLines.length; i++) {
|
|
var line = lyricsData.syncedLines[i];
|
|
lines.push({
|
|
startTimeMs: line.timeMs,
|
|
words: line.text,
|
|
endTimeMs: (i + 1 < lyricsData.syncedLines.length)
|
|
? lyricsData.syncedLines[i + 1].timeMs
|
|
: line.timeMs + 5000
|
|
});
|
|
}
|
|
return {
|
|
lines: lines,
|
|
syncType: "LINE_SYNCED",
|
|
instrumental: false,
|
|
plainLyrics: "",
|
|
provider: "Open Lyrics"
|
|
};
|
|
}
|
|
|
|
// Fallback: plain text lyrics only
|
|
if (lyricsData.plainText) {
|
|
return {
|
|
lines: [],
|
|
syncType: "UNSYNCED",
|
|
instrumental: false,
|
|
plainLyrics: lyricsData.plainText,
|
|
provider: "Open Lyrics"
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// REQUIRED: Register the extension
|
|
registerExtension({
|
|
initialize: initialize,
|
|
cleanup: cleanup,
|
|
fetchLyrics: fetchLyrics
|
|
});
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="packaging--distribution">Packaging & Distribution</h2>
|
|
<h3 id="project-structure">Project Structure</h3>
|
|
<p>Extensions support subdirectories in the package. You can organize your code like this:</p>
|
|
<pre><code>my-extension/
|
|
├── manifest.json # Required
|
|
├── index.js # Required (main entry point)
|
|
├── icon.png # Optional
|
|
├── libs/ # Optional subdirectories
|
|
│ ├── api.js
|
|
│ └── utils.js
|
|
└── assets/
|
|
└── config.json
|
|
</code></pre>
|
|
<p>When packaged as <code>.spotiflac-ext</code>, the directory structure is preserved.</p>
|
|
<h3 id="module-system-limitation">Module System Limitation</h3>
|
|
<p><strong>Important:</strong> SpotiFLAC does NOT support <code>require()</code> or ES6 <code>import</code> statements. The JavaScript runtime is a simple sandbox without Node.js module system.</p>
|
|
<p><strong>Recommended approaches:</strong></p>
|
|
<ol>
|
|
<li>
|
|
<p><strong>Single File (Recommended for simple extensions)</strong></p>
|
|
<p>Write all code in one <code>index.js</code> file.</p>
|
|
</li>
|
|
<li>
|
|
<p><strong>Bundle with a Build Tool (Recommended for complex extensions)</strong></p>
|
|
<p>Use a bundler like <a href="https://esbuild.github.io/">esbuild</a>, <a href="https://rollupjs.org/">Rollup</a>, or <a href="https://webpack.js.org/">Webpack</a> to combine multiple files into one.</p>
|
|
<pre><code class="language-bash"># Example with esbuild
|
|
npm install -g esbuild
|
|
esbuild src/index.js --bundle --outfile=dist/index.js --format=iife
|
|
</code></pre>
|
|
</li>
|
|
<li>
|
|
<p><strong>Manual Loading via File API</strong></p>
|
|
<p>If you need to load additional files at runtime, use the <code>file.read()</code> API:</p>
|
|
<pre><code class="language-javascript">// Load a JSON config file
|
|
const configData = file.read('assets/config.json');
|
|
const config = JSON.parse(configData);
|
|
|
|
// Note: You cannot "eval" or execute JS files this way for security reasons
|
|
</code></pre>
|
|
</li>
|
|
</ol>
|
|
<p><strong>Example build setup with esbuild:</strong></p>
|
|
<pre><code>my-extension/
|
|
├── src/
|
|
│ ├── index.js # Entry point
|
|
│ ├── api.js # API functions
|
|
│ └── utils.js # Utility functions
|
|
├── dist/
|
|
│ └── index.js # Bundled output
|
|
├── manifest.json
|
|
├── icon.png
|
|
└── package.json
|
|
</code></pre>
|
|
<pre><code class="language-json">// package.json
|
|
{
|
|
"scripts": {
|
|
"build": "esbuild src/index.js --bundle --outfile=dist/index.js --format=iife",
|
|
"package": "npm run build && zip -j my-extension.spotiflac-ext manifest.json dist/index.js icon.png"
|
|
}
|
|
}
|
|
</code></pre>
|
|
<h3 id="creating-extension-file">Creating Extension File</h3>
|
|
<ol>
|
|
<li>Create a folder with <code>manifest.json</code> and <code>index.js</code> files</li>
|
|
<li>ZIP the folder</li>
|
|
<li>Rename <code>.zip</code> to <code>.spotiflac-ext</code></li>
|
|
</ol>
|
|
<p><strong>Using Command Line:</strong></p>
|
|
<pre><code class="language-bash"># Windows (PowerShell)
|
|
Compress-Archive -Path manifest.json, index.js -DestinationPath my-extension.zip
|
|
Rename-Item my-extension.zip my-extension.spotiflac-ext
|
|
|
|
# Linux/Mac
|
|
zip my-extension.zip manifest.json index.js
|
|
mv my-extension.zip my-extension.spotiflac-ext
|
|
</code></pre>
|
|
<h3 id="installing-extension">Installing Extension</h3>
|
|
<ol>
|
|
<li>Open SpotiFLAC</li>
|
|
<li>Go to Settings → Extensions</li>
|
|
<li>Tap the "+" or "Import" button</li>
|
|
<li>Select the <code>.spotiflac-ext</code> file</li>
|
|
<li>Extension will be loaded and appear in the list</li>
|
|
</ol>
|
|
<h3 id="upgrading-extension">Upgrading Extension</h3>
|
|
<p>SpotiFLAC supports upgrading extensions without losing data:</p>
|
|
<ol>
|
|
<li>Create a new version of your extension with a higher version number in <code>manifest.json</code></li>
|
|
<li>Package the extension as usual</li>
|
|
<li>Install the new <code>.spotiflac-ext</code> file</li>
|
|
<li>SpotiFLAC will automatically detect it's an upgrade and:
|
|
<ul>
|
|
<li>Preserve the extension's data directory (settings, cached data)</li>
|
|
<li>Replace the extension code with the new version</li>
|
|
<li>Reload the extension</li>
|
|
</ul>
|
|
</li>
|
|
</ol>
|
|
<p><strong>Important Notes:</strong></p>
|
|
<ul>
|
|
<li><strong>Upgrades only</strong>: You can only upgrade to a higher version. Downgrading is not allowed.</li>
|
|
<li><strong>Same version</strong>: Installing the same version will show an error "Extension is already installed"</li>
|
|
<li><strong>Data preservation</strong>: User settings and stored data are preserved during upgrades</li>
|
|
</ul>
|
|
<p><strong>Version Format:</strong>
|
|
Use semantic versioning (x.y.z):</p>
|
|
<ul>
|
|
<li><code>1.0.0</code> → <code>1.0.1</code> (patch upgrade)</li>
|
|
<li><code>1.0.0</code> → <code>1.1.0</code> (minor upgrade)</li>
|
|
<li><code>1.0.0</code> → <code>2.0.0</code> (major upgrade)</li>
|
|
<li><code>1.1.0</code> → <code>1.0.0</code> (downgrade) - not allowed</li>
|
|
</ul>
|
|
<hr />
|
|
<h2 id="troubleshooting">Troubleshooting</h2>
|
|
<h3 id="error-extension-did-not-call-registerextension">Error: "extension did not call registerExtension()"</h3>
|
|
<p>The extension script did not call <code>registerExtension()</code> function.</p>
|
|
<p><strong>Solution:</strong> Add <code>registerExtension({...})</code> at the end of your script with all your provider functions. See the <a href="#main-script">Main Script</a> section for examples.</p>
|
|
<h3 id="error-permission-denied-for-domain-x--network-access-denied">Error: "Permission denied for domain X" / "network access denied"</h3>
|
|
<p>Extension is trying to access a domain not in <code>permissions.network</code>.</p>
|
|
<p><strong>Solution:</strong> Add the domain to the <code>permissions.network</code> array in manifest. For services with multiple domains (like YouTube), you need to add ALL domains:</p>
|
|
<pre><code class="language-json">"permissions": {
|
|
"network": [
|
|
"music.youtube.com",
|
|
"www.youtube.com",
|
|
"youtube.com",
|
|
"youtubei.googleapis.com",
|
|
"*.googlevideo.com",
|
|
"*.youtube.com",
|
|
"*.ytimg.com"
|
|
]
|
|
}
|
|
</code></pre>
|
|
<p><strong>Common domains for popular services:</strong></p>
|
|
<ul>
|
|
<li><strong>YouTube Music</strong>: <code>youtubei.googleapis.com</code>, <code>music.youtube.com</code>, <code>*.youtube.com</code>, <code>*.googlevideo.com</code>, <code>*.ytimg.com</code></li>
|
|
<li><strong>SoundCloud</strong>: <code>api.soundcloud.com</code>, <code>api-v2.soundcloud.com</code>, <code>*.sndcdn.com</code></li>
|
|
<li><strong>Bandcamp</strong>: <code>bandcamp.com</code>, <code>*.bandcamp.com</code>, <code>*.bcbits.com</code></li>
|
|
</ul>
|
|
<h3 id="error-post-body-is-object-object">Error: "POST body is [object Object]"</h3>
|
|
<p>The HTTP POST body is being converted to string incorrectly.</p>
|
|
<p><strong>Solution:</strong> As of v3.0.0-alpha.2, <code>http.post()</code> now automatically stringifies objects to JSON. If you're on an older version, manually stringify:</p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<h3 id="error-function-x-is-not-defined">Error: "Function X is not defined"</h3>
|
|
<p>SpotiFLAC cannot find the required function.</p>
|
|
<p><strong>Solution:</strong> Make sure <code>initialize</code> and <code>cleanup</code> functions exist. If <code>type</code> includes <code>metadata_provider</code>, ensure <code>searchTracks</code> exists. If <code>type</code> includes <code>download_provider</code>, ensure <code>checkAvailability</code>, <code>getDownloadUrl</code>, and <code>download</code> exist.</p>
|
|
<h3 id="error-invalid-manifest">Error: "Invalid manifest"</h3>
|
|
<p>The manifest.json format is invalid.</p>
|
|
<p><strong>Solution:</strong></p>
|
|
<ul>
|
|
<li>Ensure JSON is valid (use a JSON validator)</li>
|
|
<li>Ensure all required fields exist</li>
|
|
<li>Ensure <code>name</code> is lowercase without spaces</li>
|
|
</ul>
|
|
<h3 id="extension-doesnt-appear-after-install">Extension doesn't appear after install</h3>
|
|
<p><strong>Solution:</strong></p>
|
|
<ul>
|
|
<li>Ensure file is a valid ZIP</li>
|
|
<li>Ensure <code>manifest.json</code> exists at ZIP root</li>
|
|
<li>Check logs for error messages</li>
|
|
</ul>
|
|
<h3 id="http-request-fails">HTTP request fails</h3>
|
|
<p><strong>Solution:</strong></p>
|
|
<ul>
|
|
<li>Ensure domain is in <code>permissions.network</code></li>
|
|
<li>Check URL and parameters</li>
|
|
<li>Check response status and body for error messages</li>
|
|
<li>Use <code>log.debug()</code> for debugging</li>
|
|
<li>Check <code>response.ok</code> property (true if status 2xx)</li>
|
|
</ul>
|
|
<h3 id="download-fails">Download fails</h3>
|
|
<p><strong>Solution:</strong></p>
|
|
<ul>
|
|
<li>Ensure <code>storage</code> permission is in manifest</li>
|
|
<li>Ensure URL is valid and accessible</li>
|
|
<li>Check if server requires auth headers</li>
|
|
</ul>
|
|
<h3 id="error-file-access-denied-extension-does-not-have-file-permission">Error: "file access denied: extension does not have 'file' permission"</h3>
|
|
<p>Extension is trying to use file operations without the <code>file</code> permission.</p>
|
|
<p><strong>Solution:</strong> Add <code>"file": true</code> to your <code>permissions</code> in manifest.json:</p>
|
|
<pre><code class="language-json">"permissions": {
|
|
"network": ["api.example.com"],
|
|
"storage": true,
|
|
"file": true
|
|
}
|
|
</code></pre>
|
|
<h3 id="error-file-access-denied-absolute-paths-are-not-allowed">Error: "file access denied: absolute paths are not allowed"</h3>
|
|
<p>Extension is trying to access a file using an absolute path (e.g., <code>/sdcard/Music/file.flac</code> or <code>C:\Music\file.flac</code>).</p>
|
|
<p><strong>Solution:</strong> Use relative paths within the extension's sandbox directory. For download operations, the app will automatically provide the correct output path. Example:</p>
|
|
<pre><code class="language-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 });
|
|
}
|
|
</code></pre>
|
|
<h3 id="error-file-access-denied-path-x-is-outside-sandbox">Error: "file access denied: path 'X' is outside sandbox"</h3>
|
|
<p>Extension is trying to access a file outside its sandbox using path traversal (e.g., <code>../../../etc/passwd</code>).</p>
|
|
<p><strong>Solution:</strong> Only use paths within your extension's data directory. Path traversal attempts are blocked for security.</p>
|
|
<h3 id="error-cannot-downgrade-extension">Error: "Cannot downgrade extension"</h3>
|
|
<p>You're trying to install an older version of an already installed extension.</p>
|
|
<p><strong>Solution:</strong> SpotiFLAC only allows upgrades (higher version numbers). If you need to downgrade:</p>
|
|
<ol>
|
|
<li>Uninstall the current extension first</li>
|
|
<li>Then install the older version</li>
|
|
</ol>
|
|
<h3 id="error-extension-is-already-installed">Error: "Extension is already installed"</h3>
|
|
<p>You're trying to install the same version that's already installed.</p>
|
|
<p><strong>Solution:</strong> Bump the version number in <code>manifest.json</code> if you've made changes.</p>
|
|
<h3 id="error-timeout-extension-took-too-long-to-respond">Error: "timeout: extension took too long to respond"</h3>
|
|
<p>Extension function exceeded the execution time limit.</p>
|
|
<p><strong>Solution:</strong></p>
|
|
<ul>
|
|
<li>Default timeout is 30 seconds for most operations</li>
|
|
<li>Download operations have 5 minute timeout</li>
|
|
<li>Post-processing has 2 minute timeout</li>
|
|
<li>Optimize your code to avoid infinite loops or long-running operations</li>
|
|
<li>For downloads, ensure you're streaming data rather than loading everything into memory</li>
|
|
</ul>
|
|
<h3 id="thumbnails-not-showing-correctly-in-search-results">Thumbnails not showing correctly in search results</h3>
|
|
<p>Custom search results may show square thumbnails instead of the expected aspect ratio.</p>
|
|
<p><strong>Solution:</strong></p>
|
|
<ol>
|
|
<li>Add <code>thumbnailRatio</code> to your <code>searchBehavior</code> in manifest:<pre><code class="language-json">"searchBehavior": {
|
|
"enabled": true,
|
|
"thumbnailRatio": "wide" // For 16:9 YouTube-style thumbnails
|
|
}
|
|
</code></pre>
|
|
</li>
|
|
<li>Reinstall/upgrade the extension after changing the manifest</li>
|
|
<li>Make sure your <code>customSearch</code> function returns <code>images</code> field with valid URLs</li>
|
|
</ol>
|
|
<hr />
|
|
<h2 id="technical-details--behavior">Technical Details & Behavior</h2>
|
|
<p>This section clarifies implementation details that may not be obvious from the API reference.</p>
|
|
<h3 id="token-refresh-handling">Token Refresh Handling</h3>
|
|
<p>SpotiFLAC does <strong>NOT</strong> automatically refresh tokens. Extensions must handle token refresh manually.</p>
|
|
<p><strong>Recommended Pattern:</strong></p>
|
|
<pre><code class="language-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
|
|
});
|
|
}
|
|
</code></pre>
|
|
<p><strong>Key Points:</strong></p>
|
|
<ul>
|
|
<li><code>auth.getTokens().is_expired</code> returns <code>true</code> if current time > <code>expires_at</code></li>
|
|
<li>You must implement refresh logic yourself</li>
|
|
<li>Store refresh tokens using <code>credentials.store()</code> for persistence</li>
|
|
<li>Call <code>auth.setAuthCode()</code> after refresh to update the auth state</li>
|
|
</ul>
|
|
<h3 id="storage-limits">Storage Limits</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Storage Type</th>
|
|
<th>Limit</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>storage</code> API</td>
|
|
<td><strong>Unlimited</strong></td>
|
|
<td>Stored as JSON in extension's data directory</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>credentials</code> API</td>
|
|
<td><strong>Unlimited</strong></td>
|
|
<td>Encrypted with AES-GCM, stored in <code>.credentials.enc</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td>File API</td>
|
|
<td><strong>Unlimited</strong></td>
|
|
<td>Limited to extension's sandbox directory</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Storage Location:</strong></p>
|
|
<ul>
|
|
<li>Android: <code>/data/data/com.zarz.spotiflac/files/extensions/{extension-id}/</code></li>
|
|
<li>Each extension has isolated storage (cannot access other extensions' data)</li>
|
|
</ul>
|
|
<p><strong>Best Practices:</strong></p>
|
|
<ul>
|
|
<li>Don't store large binary data in <code>storage</code> - use File API instead</li>
|
|
<li>Clean up unused data in <code>cleanup()</code> function</li>
|
|
<li>Use <code>credentials</code> for sensitive data (API keys, tokens, passwords)</li>
|
|
</ul>
|
|
<h3 id="file-api-path-resolution">File API Path Resolution</h3>
|
|
<p>All File API paths are <strong>relative to the extension's data directory</strong> unless an absolute path is provided.</p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>Security:</strong></p>
|
|
<ul>
|
|
<li>Relative paths are sandboxed to extension's data directory</li>
|
|
<li>Attempting to escape sandbox (e.g., <code>../other-extension/</code>) will fail</li>
|
|
<li>Absolute paths are allowed for download queue integration (app controls these paths)</li>
|
|
</ul>
|
|
<p><strong>Extension Data Directory Structure:</strong></p>
|
|
<pre><code>{extension-id}/
|
|
├── storage.json # storage API data
|
|
├── .credentials.enc # encrypted credentials
|
|
├── cache/ # your cache files
|
|
└── downloads/ # your download files
|
|
</code></pre>
|
|
<h3 id="http-redirect-handling">HTTP Redirect Handling</h3>
|
|
<p>HTTP redirects are handled <strong>automatically</strong> by the HTTP client (follows redirects by default).</p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>Behavior:</strong></p>
|
|
<ul>
|
|
<li>301, 302, 303, 307, 308 redirects are followed automatically</li>
|
|
<li>Cookies are preserved across redirects (same domain)</li>
|
|
<li>Maximum 10 redirects (Go's http.Client default)</li>
|
|
<li>Final response is returned (not intermediate redirects)</li>
|
|
</ul>
|
|
<p><strong>If you need to prevent redirects</strong> (rare), you can check the response and handle manually:</p>
|
|
<pre><code class="language-javascript">// Most cases: just use the response directly
|
|
const response = http.get(url);
|
|
if (response.ok) {
|
|
// Final response after any redirects
|
|
}
|
|
</code></pre>
|
|
<h3 id="standard-error-types">Standard Error Types</h3>
|
|
<p>Use these standard <code>error_type</code> values in download results for consistent error handling:</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>error_type</th>
|
|
<th>Description</th>
|
|
<th>User Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>not_found</code></td>
|
|
<td>Track not available on this service</td>
|
|
<td>Try another service</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>auth_error</code></td>
|
|
<td>Authentication failed or expired</td>
|
|
<td>Re-authenticate</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>rate_limit</code></td>
|
|
<td>Too many requests, rate limited</td>
|
|
<td>Wait and retry</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>geo_blocked</code></td>
|
|
<td>Content not available in user's region</td>
|
|
<td>Use VPN or try another service</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>stream_error</code></td>
|
|
<td>Failed to get stream URL</td>
|
|
<td>Retry or try another quality</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>download_error</code></td>
|
|
<td>File download failed</td>
|
|
<td>Check network and retry</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>format_error</code></td>
|
|
<td>Unsupported or invalid format</td>
|
|
<td>Try another quality</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>quota_exceeded</code></td>
|
|
<td>User's download quota exceeded</td>
|
|
<td>Wait for quota reset</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>premium_required</code></td>
|
|
<td>Premium subscription required</td>
|
|
<td>Upgrade account</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>server_error</code></td>
|
|
<td>Service temporarily unavailable</td>
|
|
<td>Retry later</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p><strong>Example Usage:</strong></p>
|
|
<pre><code class="language-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
|
|
};
|
|
}
|
|
</code></pre>
|
|
<h3 id="http-timeout">HTTP Timeout</h3>
|
|
<p>The HTTP client has a <strong>30 second timeout</strong> for all requests.</p>
|
|
<pre><code class="language-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);
|
|
}
|
|
</code></pre>
|
|
<p><strong>For large file downloads</strong>, use <code>file.download()</code> which has a longer timeout and supports progress callbacks.</p>
|
|
<hr />
|
|
<h2 id="tips--best-practices">Tips & Best Practices</h2>
|
|
<ol>
|
|
<li><strong>Always handle errors</strong> - Wrap HTTP calls in try-catch</li>
|
|
<li><strong>Use logging</strong> - <code>log.debug()</code> is very helpful for debugging</li>
|
|
<li><strong>Validate settings</strong> - Check required settings in <code>initialize()</code></li>
|
|
<li><strong>Cache tokens</strong> - Use <code>storage</code> to save auth tokens</li>
|
|
<li><strong>Respect rate limits</strong> - Don't spam APIs</li>
|
|
<li><strong>Test thoroughly</strong> - Test with various inputs before distribution</li>
|
|
<li><strong>List all domains</strong> - For complex APIs (YouTube, etc.), list ALL required domains in permissions</li>
|
|
</ol>
|
|
<hr />
|
|
<h2 id="authentication-api">Authentication API</h2>
|
|
<p>SpotiFLAC provides a built-in authentication system for extensions that need OAuth or other auth flows (e.g., Apple Music, Spotify Premium, etc.).</p>
|
|
<h3 id="auth-api-reference">Auth API Reference</h3>
|
|
<pre><code class="language-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();
|
|
</code></pre>
|
|
<h3 id="credentials-api-encrypted-storage">Credentials API (Encrypted Storage)</h3>
|
|
<p>For storing sensitive data like API keys, passwords, or tokens, use the encrypted credentials API:</p>
|
|
<pre><code class="language-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");
|
|
</code></pre>
|
|
<h3 id="crypto-utilities-1">Crypto Utilities</h3>
|
|
<p>For custom encryption needs:</p>
|
|
<pre><code class="language-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" }
|
|
</code></pre>
|
|
<h3 id="oauth-flow-example">OAuth Flow Example</h3>
|
|
<p>Here's a complete example of implementing OAuth authentication:</p>
|
|
<pre><code class="language-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
|
|
});
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="data-schema-reference">Data Schema Reference</h2>
|
|
<h3 id="track-object">Track Object</h3>
|
|
<pre><code class="language-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)
|
|
}
|
|
</code></pre>
|
|
<p><strong>Note on <code>images</code> field:</strong></p>
|
|
<ul>
|
|
<li>For custom search results, this URL will be displayed as the track thumbnail</li>
|
|
<li>The aspect ratio is controlled by <code>searchBehavior.thumbnailRatio</code> in your manifest</li>
|
|
<li>Use high-quality URLs for best display (recommended: 300x300 for square, 480x270 for wide)</li>
|
|
</ul>
|
|
<h3 id="album-object">Album Object</h3>
|
|
<pre><code class="language-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 */]
|
|
}
|
|
</code></pre>
|
|
<h3 id="artist-object">Artist Object</h3>
|
|
<pre><code class="language-javascript">{
|
|
id: "artist123",
|
|
name: "Artist Name",
|
|
images: "https://...",
|
|
albums: [/* array of Album objects */]
|
|
}
|
|
</code></pre>
|
|
<h3 id="download-result-object">Download Result Object</h3>
|
|
<pre><code class="language-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"
|
|
}
|
|
</code></pre>
|
|
<h3 id="lyrics-result-object">Lyrics Result Object</h3>
|
|
<p>Returned by the <code>fetchLyrics()</code> function in lyrics provider extensions.</p>
|
|
<pre><code class="language-javascript">// Synced lyrics
|
|
{
|
|
lines: [
|
|
{ startTimeMs: 1200, words: "First line of lyrics", endTimeMs: 4500 },
|
|
{ startTimeMs: 4500, words: "Second line of lyrics", endTimeMs: 8200 }
|
|
],
|
|
syncType: "LINE_SYNCED", // "LINE_SYNCED", "WORD_SYNCED", or "UNSYNCED"
|
|
instrumental: false,
|
|
plainLyrics: "",
|
|
provider: "My Lyrics Source"
|
|
}
|
|
|
|
// Plain text lyrics (no timestamps)
|
|
{
|
|
lines: [],
|
|
syncType: "UNSYNCED",
|
|
instrumental: false,
|
|
plainLyrics: "Line one\nLine two\nLine three",
|
|
provider: "My Lyrics Source"
|
|
}
|
|
|
|
// Instrumental track
|
|
{
|
|
lines: [],
|
|
syncType: "LINE_SYNCED",
|
|
instrumental: true,
|
|
plainLyrics: "",
|
|
provider: "My Lyrics Source"
|
|
}
|
|
</code></pre>
|
|
<p><strong>Lyrics line fields:</strong></p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>startTimeMs</code></td>
|
|
<td>number</td>
|
|
<td>Line start time in milliseconds</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>words</code></td>
|
|
<td>string</td>
|
|
<td>The lyric text for this line</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>endTimeMs</code></td>
|
|
<td>number</td>
|
|
<td>Line end time in milliseconds (0 if unknown; auto-computed from next line)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h3 id="skip-metadata-enrichment">Skip Metadata Enrichment</h3>
|
|
<p>When <code>skipMetadataEnrichment</code> is set to <code>true</code> in the manifest, SpotiFLAC will use the metadata returned by the extension's <code>download()</code> function instead of enriching from Deezer/Spotify. This is useful for:</p>
|
|
<ul>
|
|
<li><strong>YouTube downloads</strong>: The source already has metadata, no need to search Deezer/Spotify</li>
|
|
<li><strong>Direct source downloads</strong>: When the extension provides complete metadata from its source</li>
|
|
<li><strong>Performance</strong>: Skip unnecessary API calls to metadata providers</li>
|
|
</ul>
|
|
<p>To use this feature:</p>
|
|
<ol>
|
|
<li>Set <code>"skipMetadataEnrichment": true</code> in your manifest.json</li>
|
|
<li>Return metadata fields in your <code>download()</code> function result:</li>
|
|
</ol>
|
|
<pre><code class="language-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
|
|
};
|
|
}
|
|
</code></pre>
|
|
<hr />
|
|
<h2 id="changelog">Changelog</h2>
|
|
<ul>
|
|
<li><strong>v1.3</strong> - Added <code>lyrics_provider</code> extension type, <code>fetchLyrics</code> API, lyrics result schema, and Lyrics Provider example</li>
|
|
<li><strong>v1.2</strong> - Added thumbnail ratio customization (<code>thumbnailRatio</code>, <code>thumbnailWidth</code>, <code>thumbnailHeight</code>)</li>
|
|
<li><strong>v1.1</strong> - Added extension upgrade support (no downgrade), improved documentation</li>
|
|
<li><strong>v1.0</strong> - Initial release</li>
|
|
</ul>
|
|
<hr />
|
|
<h2 id="support">Support</h2>
|
|
<p>If you have questions or issues:</p>
|
|
<ol>
|
|
<li>Open an issue on the GitHub repository</li>
|
|
<li>Include error logs and reproduction steps</li>
|
|
<li>Include SpotiFLAC and extension versions</li>
|
|
</ol>
|
|
<p>Happy coding!</p>
|
|
|
|
</article>
|
|
</main>
|
|
|
|
<aside class="docs-onpage" aria-label="On this page">
|
|
<div class="panel">
|
|
<h2>On this page</h2>
|
|
<div id="onThisPage"></div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- Search modal -->
|
|
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
|
<div class="search-modal">
|
|
<div class="search-header">
|
|
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
|
<input class="search-input" id="searchInput" type="text" placeholder="Search documentation..." autocomplete="off" spellcheck="false">
|
|
<button class="search-esc" onclick="closeSearch()">Esc</button>
|
|
</div>
|
|
<div class="search-body" id="searchBody">
|
|
<div class="search-empty">Type to search across all documentation sections</div>
|
|
</div>
|
|
<div class="search-footer">
|
|
<span><kbd>↑</kbd> <kbd>↓</kbd> to navigate</span>
|
|
<span><kbd>Enter</kbd> to select</span>
|
|
<span><kbd>Esc</kbd> to close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<div class="footer-inner">
|
|
<div class="footer-links">
|
|
<a href="index">Home</a>
|
|
<a href="downloads">Downloads</a>
|
|
<a href="docs">Documentation</a>
|
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank">GitHub</a>
|
|
<a href="https://t.me/spotiflac" target="_blank">Telegram</a>
|
|
<a href="https://ko-fi.com/zarzet" target="_blank">Support / Ko-fi</a>
|
|
</div>
|
|
<p class="footer-copy">SpotiFLAC is for educational and private use only. Not affiliated with any streaming service.</p>
|
|
<p class="footer-copy">© 2026 SpotiFLAC · Open Source · <a href="https://opensource.org/license/mit" target="_blank" style="color:inherit;text-decoration:underline">MIT Licensed</a></p>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
const sectionsData = [{"id": "table-of-contents", "title": "Table of Contents", "children": [], "items": [{"id": "table-of-contents", "title": "Table of Contents", "level": 2}]}, {"id": "introduction", "title": "Introduction", "children": [{"id": "requirements", "title": "Requirements", "level": 3}], "items": [{"id": "introduction", "title": "Introduction", "level": 2}, {"id": "requirements", "title": "Requirements", "level": 3}]}, {"id": "extension-structure", "title": "Extension Structure", "children": [], "items": [{"id": "extension-structure", "title": "Extension Structure", "level": 2}]}, {"id": "manifest-file", "title": "Manifest File", "children": [{"id": "complete-manifest-example", "title": "Complete Manifest Example", "level": 3}, {"id": "manifest-fields", "title": "Manifest Fields", "level": 3}, {"id": "quality-options", "title": "Quality Options", "level": 3}, {"id": "quality-specific-settings", "title": "Quality-Specific Settings", "level": 3}, {"id": "permissions", "title": "Permissions", "level": 3}, {"id": "extension-types", "title": "Extension Types", "level": 3}, {"id": "settings", "title": "Settings", "level": 3}, {"id": "button-setting-type", "title": "Button Setting Type", "level": 3}, {"id": "custom-search-behavior", "title": "Custom Search Behavior", "level": 3}, {"id": "custom-url-handler", "title": "Custom URL Handler", "level": 3}, {"id": "album--playlist-functions-v301", "title": "Album & Playlist Functions (v3.0.1+)", "level": 3}, {"id": "artist-support", "title": "Artist Support", "level": 3}, {"id": "home-feed-support", "title": "Home Feed Support", "level": 3}, {"id": "track-enrichment", "title": "Track Enrichment", "level": 3}, {"id": "custom-track-matching", "title": "Custom Track Matching", "level": 3}, {"id": "post-processing-hooks", "title": "Post-Processing Hooks", "level": 3}, {"id": "lyrics-provider", "title": "Lyrics Provider", "level": 3}], "items": [{"id": "manifest-file", "title": "Manifest File", "level": 2}, {"id": "complete-manifest-example", "title": "Complete Manifest Example", "level": 3}, {"id": "manifest-fields", "title": "Manifest Fields", "level": 3}, {"id": "quality-options", "title": "Quality Options", "level": 3}, {"id": "quality-specific-settings", "title": "Quality-Specific Settings", "level": 3}, {"id": "permissions", "title": "Permissions", "level": 3}, {"id": "extension-types", "title": "Extension Types", "level": 3}, {"id": "settings", "title": "Settings", "level": 3}, {"id": "button-setting-type", "title": "Button Setting Type", "level": 3}, {"id": "custom-search-behavior", "title": "Custom Search Behavior", "level": 3}, {"id": "thumbnail-ratio-presets", "title": "Thumbnail Ratio Presets", "level": 4}, {"id": "custom-url-handler", "title": "Custom URL Handler", "level": 3}, {"id": "album--playlist-functions-v301", "title": "Album & Playlist Functions (v3.0.1+)", "level": 3}, {"id": "artist-support", "title": "Artist Support", "level": 3}, {"id": "home-feed-support", "title": "Home Feed Support", "level": 3}, {"id": "track-enrichment", "title": "Track Enrichment", "level": 3}, {"id": "custom-track-matching", "title": "Custom Track Matching", "level": 3}, {"id": "post-processing-hooks", "title": "Post-Processing Hooks", "level": 3}, {"id": "post-process-api-v2-recommended", "title": "Post-Process API v2 (Recommended)", "level": 4}, {"id": "lyrics-provider", "title": "Lyrics Provider", "level": 3}]}, {"id": "main-script", "title": "Main Script", "children": [{"id": "basic-structure", "title": "Basic Structure", "level": 3}, {"id": "important-registerextension", "title": "Important: registerExtension()", "level": 3}], "items": [{"id": "main-script", "title": "Main Script", "level": 2}, {"id": "basic-structure", "title": "Basic Structure", "level": 3}, {"id": "important-registerextension", "title": "Important: registerExtension()", "level": 3}]}, {"id": "api-reference", "title": "API Reference", "children": [{"id": "http-api", "title": "HTTP API", "level": 3}, {"id": "browser-like-polyfills", "title": "Browser-like Polyfills", "level": 3}, {"id": "storage-api", "title": "Storage API", "level": 3}, {"id": "file-api", "title": "File API", "level": 3}, {"id": "logging-api", "title": "Logging API", "level": 3}, {"id": "utility-api", "title": "Utility API", "level": 3}, {"id": "go-backend-api", "title": "Go Backend API", "level": 3}, {"id": "credentials-api-encrypted", "title": "Credentials API (Encrypted)", "level": 3}, {"id": "auth-api-oauth-support", "title": "Auth API (OAuth Support)", "level": 3}, {"id": "pkce-oauth-flow-recommended", "title": "PKCE OAuth Flow (Recommended)", "level": 3}, {"id": "crypto-utilities", "title": "Crypto Utilities", "level": 3}, {"id": "ffmpeg-api-post-processing", "title": "FFmpeg API (Post-Processing)", "level": 3}, {"id": "track-matching-api", "title": "Track Matching API", "level": 3}], "items": [{"id": "api-reference", "title": "API Reference", "level": 2}, {"id": "http-api", "title": "HTTP API", "level": 3}, {"id": "request-headers", "title": "Request Headers", "level": 4}, {"id": "response-object", "title": "Response Object", "level": 4}, {"id": "form-encoded-post-applicationx-www-form-urlencoded", "title": "Form-Encoded POST (application/x-www-form-urlencoded)", "level": 4}, {"id": "cookie-jar", "title": "Cookie Jar", "level": 4}, {"id": "youtube-music--innertube-api-example", "title": "YouTube Music / Innertube API Example", "level": 4}, {"id": "browser-like-polyfills", "title": "Browser-like Polyfills", "level": 3}, {"id": "fetch-api", "title": "fetch() API", "level": 4}, {"id": "atob--btoa", "title": "atob() / btoa()", "level": 4}, {"id": "textencoder--textdecoder", "title": "TextEncoder / TextDecoder", "level": 4}, {"id": "url--urlsearchparams", "title": "URL / URLSearchParams", "level": 4}, {"id": "porting-browser-libraries", "title": "Porting Browser Libraries", "level": 4}, {"id": "storage-api", "title": "Storage API", "level": 3}, {"id": "file-api", "title": "File API", "level": 3}, {"id": "logging-api", "title": "Logging API", "level": 3}, {"id": "utility-api", "title": "Utility API", "level": 3}, {"id": "hmac-sha1-for-totp", "title": "HMAC-SHA1 for TOTP", "level": 4}, {"id": "hmac-sha256-example-api-signing", "title": "HMAC-SHA256 Example (API Signing)", "level": 4}, {"id": "go-backend-api", "title": "Go Backend API", "level": 3}, {"id": "using-getlocaltime-for-time-based-greeting", "title": "Using `getLocalTime()` for Time-Based Greeting", "level": 4}, {"id": "using-getlocaltime-for-timezone-in-api-calls", "title": "Using `getLocalTime()` for Timezone in API Calls", "level": 4}, {"id": "credentials-api-encrypted", "title": "Credentials API (Encrypted)", "level": 3}, {"id": "auth-api-oauth-support", "title": "Auth API (OAuth Support)", "level": 3}, {"id": "pkce-oauth-flow-recommended", "title": "PKCE OAuth Flow (Recommended)", "level": 3}, {"id": "quick-start-high-level-api", "title": "Quick Start (High-Level API)", "level": 4}, {"id": "low-level-api-manual-control", "title": "Low-Level API (Manual Control)", "level": 4}, {"id": "pkce-api-reference", "title": "PKCE API Reference", "level": 4}, {"id": "complete-oauth-example", "title": "Complete OAuth Example", "level": 4}, {"id": "crypto-utilities", "title": "Crypto Utilities", "level": 3}, {"id": "ffmpeg-api-post-processing", "title": "FFmpeg API (Post-Processing)", "level": 3}, {"id": "track-matching-api", "title": "Track Matching API", "level": 3}]}, {"id": "extension-examples", "title": "Extension Examples", "children": [{"id": "example-1-simple-metadata-provider", "title": "Example 1: Simple Metadata Provider", "level": 3}, {"id": "example-2-download-provider-with-auth", "title": "Example 2: Download Provider with Auth", "level": 3}, {"id": "example-3-lyrics-provider", "title": "Example 3: Lyrics Provider", "level": 3}], "items": [{"id": "extension-examples", "title": "Extension Examples", "level": 2}, {"id": "example-1-simple-metadata-provider", "title": "Example 1: Simple Metadata Provider", "level": 3}, {"id": "example-2-download-provider-with-auth", "title": "Example 2: Download Provider with Auth", "level": 3}, {"id": "example-3-lyrics-provider", "title": "Example 3: Lyrics Provider", "level": 3}]}, {"id": "packaging--distribution", "title": "Packaging & Distribution", "children": [{"id": "project-structure", "title": "Project Structure", "level": 3}, {"id": "module-system-limitation", "title": "Module System Limitation", "level": 3}, {"id": "creating-extension-file", "title": "Creating Extension File", "level": 3}, {"id": "installing-extension", "title": "Installing Extension", "level": 3}, {"id": "upgrading-extension", "title": "Upgrading Extension", "level": 3}], "items": [{"id": "packaging--distribution", "title": "Packaging & Distribution", "level": 2}, {"id": "project-structure", "title": "Project Structure", "level": 3}, {"id": "module-system-limitation", "title": "Module System Limitation", "level": 3}, {"id": "creating-extension-file", "title": "Creating Extension File", "level": 3}, {"id": "installing-extension", "title": "Installing Extension", "level": 3}, {"id": "upgrading-extension", "title": "Upgrading Extension", "level": 3}]}, {"id": "troubleshooting", "title": "Troubleshooting", "children": [{"id": "error-extension-did-not-call-registerextension", "title": "Error: \"extension did not call registerExtension()\"", "level": 3}, {"id": "error-permission-denied-for-domain-x--network-access-denied", "title": "Error: \"Permission denied for domain X\" / \"network access denied\"", "level": 3}, {"id": "error-post-body-is-object-object", "title": "Error: \"POST body is [object Object]\"", "level": 3}, {"id": "error-function-x-is-not-defined", "title": "Error: \"Function X is not defined\"", "level": 3}, {"id": "error-invalid-manifest", "title": "Error: \"Invalid manifest\"", "level": 3}, {"id": "extension-doesnt-appear-after-install", "title": "Extension doesn't appear after install", "level": 3}, {"id": "http-request-fails", "title": "HTTP request fails", "level": 3}, {"id": "download-fails", "title": "Download fails", "level": 3}, {"id": "error-file-access-denied-extension-does-not-have-file-permission", "title": "Error: \"file access denied: extension does not have 'file' permission\"", "level": 3}, {"id": "error-file-access-denied-absolute-paths-are-not-allowed", "title": "Error: \"file access denied: absolute paths are not allowed\"", "level": 3}, {"id": "error-file-access-denied-path-x-is-outside-sandbox", "title": "Error: \"file access denied: path 'X' is outside sandbox\"", "level": 3}, {"id": "error-cannot-downgrade-extension", "title": "Error: \"Cannot downgrade extension\"", "level": 3}, {"id": "error-extension-is-already-installed", "title": "Error: \"Extension is already installed\"", "level": 3}, {"id": "error-timeout-extension-took-too-long-to-respond", "title": "Error: \"timeout: extension took too long to respond\"", "level": 3}, {"id": "thumbnails-not-showing-correctly-in-search-results", "title": "Thumbnails not showing correctly in search results", "level": 3}], "items": [{"id": "troubleshooting", "title": "Troubleshooting", "level": 2}, {"id": "error-extension-did-not-call-registerextension", "title": "Error: \"extension did not call registerExtension()\"", "level": 3}, {"id": "error-permission-denied-for-domain-x--network-access-denied", "title": "Error: \"Permission denied for domain X\" / \"network access denied\"", "level": 3}, {"id": "error-post-body-is-object-object", "title": "Error: \"POST body is [object Object]\"", "level": 3}, {"id": "error-function-x-is-not-defined", "title": "Error: \"Function X is not defined\"", "level": 3}, {"id": "error-invalid-manifest", "title": "Error: \"Invalid manifest\"", "level": 3}, {"id": "extension-doesnt-appear-after-install", "title": "Extension doesn't appear after install", "level": 3}, {"id": "http-request-fails", "title": "HTTP request fails", "level": 3}, {"id": "download-fails", "title": "Download fails", "level": 3}, {"id": "error-file-access-denied-extension-does-not-have-file-permission", "title": "Error: \"file access denied: extension does not have 'file' permission\"", "level": 3}, {"id": "error-file-access-denied-absolute-paths-are-not-allowed", "title": "Error: \"file access denied: absolute paths are not allowed\"", "level": 3}, {"id": "error-file-access-denied-path-x-is-outside-sandbox", "title": "Error: \"file access denied: path 'X' is outside sandbox\"", "level": 3}, {"id": "error-cannot-downgrade-extension", "title": "Error: \"Cannot downgrade extension\"", "level": 3}, {"id": "error-extension-is-already-installed", "title": "Error: \"Extension is already installed\"", "level": 3}, {"id": "error-timeout-extension-took-too-long-to-respond", "title": "Error: \"timeout: extension took too long to respond\"", "level": 3}, {"id": "thumbnails-not-showing-correctly-in-search-results", "title": "Thumbnails not showing correctly in search results", "level": 3}]}, {"id": "technical-details--behavior", "title": "Technical Details & Behavior", "children": [{"id": "token-refresh-handling", "title": "Token Refresh Handling", "level": 3}, {"id": "storage-limits", "title": "Storage Limits", "level": 3}, {"id": "file-api-path-resolution", "title": "File API Path Resolution", "level": 3}, {"id": "http-redirect-handling", "title": "HTTP Redirect Handling", "level": 3}, {"id": "standard-error-types", "title": "Standard Error Types", "level": 3}, {"id": "http-timeout", "title": "HTTP Timeout", "level": 3}], "items": [{"id": "technical-details--behavior", "title": "Technical Details & Behavior", "level": 2}, {"id": "token-refresh-handling", "title": "Token Refresh Handling", "level": 3}, {"id": "storage-limits", "title": "Storage Limits", "level": 3}, {"id": "file-api-path-resolution", "title": "File API Path Resolution", "level": 3}, {"id": "http-redirect-handling", "title": "HTTP Redirect Handling", "level": 3}, {"id": "standard-error-types", "title": "Standard Error Types", "level": 3}, {"id": "http-timeout", "title": "HTTP Timeout", "level": 3}]}, {"id": "tips--best-practices", "title": "Tips & Best Practices", "children": [], "items": [{"id": "tips--best-practices", "title": "Tips & Best Practices", "level": 2}]}, {"id": "authentication-api", "title": "Authentication API", "children": [{"id": "auth-api-reference", "title": "Auth API Reference", "level": 3}, {"id": "credentials-api-encrypted-storage", "title": "Credentials API (Encrypted Storage)", "level": 3}, {"id": "crypto-utilities-1", "title": "Crypto Utilities", "level": 3}, {"id": "oauth-flow-example", "title": "OAuth Flow Example", "level": 3}], "items": [{"id": "authentication-api", "title": "Authentication API", "level": 2}, {"id": "auth-api-reference", "title": "Auth API Reference", "level": 3}, {"id": "credentials-api-encrypted-storage", "title": "Credentials API (Encrypted Storage)", "level": 3}, {"id": "crypto-utilities-1", "title": "Crypto Utilities", "level": 3}, {"id": "oauth-flow-example", "title": "OAuth Flow Example", "level": 3}]}, {"id": "data-schema-reference", "title": "Data Schema Reference", "children": [{"id": "track-object", "title": "Track Object", "level": 3}, {"id": "album-object", "title": "Album Object", "level": 3}, {"id": "artist-object", "title": "Artist Object", "level": 3}, {"id": "download-result-object", "title": "Download Result Object", "level": 3}, {"id": "lyrics-result-object", "title": "Lyrics Result Object", "level": 3}, {"id": "skip-metadata-enrichment", "title": "Skip Metadata Enrichment", "level": 3}], "items": [{"id": "data-schema-reference", "title": "Data Schema Reference", "level": 2}, {"id": "track-object", "title": "Track Object", "level": 3}, {"id": "album-object", "title": "Album Object", "level": 3}, {"id": "artist-object", "title": "Artist Object", "level": 3}, {"id": "download-result-object", "title": "Download Result Object", "level": 3}, {"id": "lyrics-result-object", "title": "Lyrics Result Object", "level": 3}, {"id": "skip-metadata-enrichment", "title": "Skip Metadata Enrichment", "level": 3}]}, {"id": "changelog", "title": "Changelog", "children": [], "items": [{"id": "changelog", "title": "Changelog", "level": 2}]}, {"id": "support", "title": "Support", "children": [], "items": [{"id": "support", "title": "Support", "level": 2}]}];
|
|
const sectionMap = new Map(sectionsData.map(section => [section.id, section]));
|
|
const headingToSection = new Map();
|
|
for (const section of sectionsData) {
|
|
for (const item of section.items) {
|
|
headingToSection.set(item.id, section.id);
|
|
}
|
|
}
|
|
|
|
function toggleMenu() {
|
|
document.getElementById('mobileMenu').classList.toggle('open');
|
|
document.getElementById('mobileOverlay').classList.toggle('open');
|
|
document.querySelector('.nav-burger').classList.toggle('active');
|
|
}
|
|
|
|
document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
|
if (e.target.closest('a')) toggleMenu();
|
|
});
|
|
|
|
function openDrawer() {
|
|
var nav = document.getElementById('sectionNav');
|
|
var body = document.getElementById('drawerBody');
|
|
body.innerHTML = nav.innerHTML;
|
|
document.getElementById('drawerOverlay').classList.add('open');
|
|
document.getElementById('docsDrawer').classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
body.querySelectorAll('.section-link').forEach(function(link) {
|
|
link.addEventListener('click', function() { closeDrawer(); });
|
|
});
|
|
}
|
|
function closeDrawer() {
|
|
document.getElementById('drawerOverlay').classList.remove('open');
|
|
document.getElementById('docsDrawer').classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
const contentHeadings = Array.from(document.querySelectorAll('#docContent h2[id], #docContent h3[id], #docContent h4[id]'));
|
|
const sectionLinks = Array.from(document.querySelectorAll('#sectionNav .section-link'));
|
|
const onThisPageNav = document.getElementById('onThisPage');
|
|
|
|
function escapeHtml(value) {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderOnThisPage(sectionId, activeHeadingId) {
|
|
const section = sectionMap.get(sectionId) || sectionsData[0];
|
|
if (!section) {
|
|
onThisPageNav.innerHTML = '<div class="onpage-empty">No section selected.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!section.items || !section.items.length) {
|
|
onThisPageNav.innerHTML = '<div class="onpage-empty">No headings found.</div>';
|
|
return;
|
|
}
|
|
|
|
const htmlItems = section.items
|
|
.map(item => {
|
|
const activeClass = item.id === activeHeadingId ? ' active' : '';
|
|
return `<a class="onpage-link level-${item.level}${activeClass}" href="#${item.id}">${escapeHtml(item.title)}</a>`;
|
|
})
|
|
.join('');
|
|
|
|
onThisPageNav.innerHTML = htmlItems;
|
|
}
|
|
|
|
function getActiveHeadingId() {
|
|
const scrollY = window.scrollY + 110;
|
|
let active = contentHeadings[0] ? contentHeadings[0].id : null;
|
|
|
|
for (const heading of contentHeadings) {
|
|
if (heading.offsetTop <= scrollY) {
|
|
active = heading.id;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return active;
|
|
}
|
|
|
|
function updateActiveState() {
|
|
if (!contentHeadings.length) return;
|
|
|
|
const activeHeadingId = getActiveHeadingId();
|
|
const activeSectionId = headingToSection.get(activeHeadingId) || sectionsData[0]?.id;
|
|
|
|
for (const link of sectionLinks) {
|
|
const target = link.getAttribute('href').slice(1);
|
|
const level = Number(link.dataset.level || '2');
|
|
const isActive = target === activeHeadingId || (level === 2 && target === activeSectionId);
|
|
link.classList.toggle('active', isActive);
|
|
}
|
|
|
|
renderOnThisPage(activeSectionId, activeHeadingId);
|
|
}
|
|
|
|
window.addEventListener('scroll', updateActiveState, { passive: true });
|
|
window.addEventListener('resize', updateActiveState);
|
|
window.addEventListener('hashchange', () => setTimeout(updateActiveState, 10));
|
|
|
|
updateActiveState();
|
|
|
|
/* ── SEARCH ── */
|
|
(function() {
|
|
const overlay = document.getElementById('searchOverlay');
|
|
const input = document.getElementById('searchInput');
|
|
const body = document.getElementById('searchBody');
|
|
const hintEl = document.getElementById('searchShortcutHint');
|
|
let activeIdx = -1;
|
|
let results = [];
|
|
|
|
// Detect macOS for shortcut hint
|
|
if (navigator.platform && navigator.platform.indexOf('Mac') > -1) {
|
|
if (hintEl) hintEl.textContent = '\u2318 K';
|
|
}
|
|
|
|
// Build search index from headings + nearby text
|
|
const searchIndex = [];
|
|
const allHeadings = document.querySelectorAll('#docContent h1[id], #docContent h2[id], #docContent h3[id], #docContent h4[id]');
|
|
allHeadings.forEach(function(h) {
|
|
// Get the parent section title for context
|
|
const parentSectionId = headingToSection.get(h.id);
|
|
const parentSection = parentSectionId ? sectionMap.get(parentSectionId) : null;
|
|
const sectionTitle = parentSection ? parentSection.title : '';
|
|
|
|
// Get preview text from siblings
|
|
let preview = '';
|
|
let next = h.nextElementSibling;
|
|
let chars = 0;
|
|
while (next && chars < 160) {
|
|
if (/^H[1-4]$/i.test(next.tagName)) break;
|
|
const txt = next.textContent || '';
|
|
preview += txt + ' ';
|
|
chars += txt.length;
|
|
next = next.nextElementSibling;
|
|
}
|
|
preview = preview.trim().substring(0, 160);
|
|
|
|
searchIndex.push({
|
|
id: h.id,
|
|
title: h.textContent.trim(),
|
|
level: parseInt(h.tagName.charAt(1)),
|
|
section: sectionTitle,
|
|
preview: preview.toLowerCase(),
|
|
previewRaw: preview
|
|
});
|
|
});
|
|
|
|
function escHtml(s) {
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function highlight(text, query) {
|
|
if (!query) return escHtml(text);
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp('(' + escaped + ')', 'gi');
|
|
return escHtml(text).replace(re, '<mark>$1</mark>');
|
|
}
|
|
|
|
function doSearch(query) {
|
|
query = query.trim().toLowerCase();
|
|
if (!query) {
|
|
body.innerHTML = '<div class="search-empty">Type to search across all documentation sections</div>';
|
|
results = [];
|
|
activeIdx = -1;
|
|
return;
|
|
}
|
|
|
|
const tokens = query.split(/\s+/).filter(Boolean);
|
|
const scored = [];
|
|
|
|
searchIndex.forEach(function(item) {
|
|
const titleLow = item.title.toLowerCase();
|
|
const sectionLow = item.section.toLowerCase();
|
|
let score = 0;
|
|
|
|
for (const token of tokens) {
|
|
if (titleLow.includes(token)) {
|
|
score += titleLow === token ? 100 : titleLow.startsWith(token) ? 60 : 30;
|
|
} else if (sectionLow.includes(token)) {
|
|
score += 10;
|
|
} else if (item.preview.includes(token)) {
|
|
score += 5;
|
|
}
|
|
}
|
|
|
|
// Boost h2 over h3/h4
|
|
if (score > 0 && item.level === 2) score += 8;
|
|
if (score > 0 && item.level === 1) score += 15;
|
|
|
|
if (score > 0) scored.push({ item: item, score: score });
|
|
});
|
|
|
|
scored.sort(function(a, b) { return b.score - a.score; });
|
|
results = scored.slice(0, 20);
|
|
activeIdx = results.length > 0 ? 0 : -1;
|
|
|
|
if (!results.length) {
|
|
body.innerHTML = '<div class="search-empty">No results found for "' + escHtml(query) + '"</div>';
|
|
return;
|
|
}
|
|
|
|
const hashIcon = '<svg viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
|
|
|
let html = '';
|
|
results.forEach(function(r, i) {
|
|
const cls = i === activeIdx ? ' active' : '';
|
|
const sectionHint = r.item.section && r.item.section !== r.item.title ? r.item.section : '';
|
|
html += '<div class="search-item' + cls + '" data-idx="' + i + '" data-id="' + r.item.id + '">' +
|
|
hashIcon +
|
|
'<div class="search-item-text">' +
|
|
'<div class="search-item-title">' + highlight(r.item.title, query) + '</div>' +
|
|
(sectionHint ? '<div class="search-item-section">' + escHtml(sectionHint) + '</div>' : '') +
|
|
'</div>' +
|
|
'<span class="search-enter">\u21B5</span>' +
|
|
'</div>';
|
|
});
|
|
|
|
body.innerHTML = html;
|
|
|
|
body.querySelectorAll('.search-item').forEach(function(el) {
|
|
el.addEventListener('click', function() {
|
|
navigateTo(el.dataset.id);
|
|
});
|
|
el.addEventListener('mouseenter', function() {
|
|
setActive(parseInt(el.dataset.idx));
|
|
});
|
|
});
|
|
}
|
|
|
|
function setActive(idx) {
|
|
if (idx === activeIdx) return;
|
|
const items = body.querySelectorAll('.search-item');
|
|
if (items[activeIdx]) items[activeIdx].classList.remove('active');
|
|
activeIdx = idx;
|
|
if (items[activeIdx]) {
|
|
items[activeIdx].classList.add('active');
|
|
items[activeIdx].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function navigateTo(id) {
|
|
closeSearch();
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
history.replaceState(null, '', '#' + id);
|
|
setTimeout(updateActiveState, 100);
|
|
}
|
|
}
|
|
|
|
window.openSearch = function() {
|
|
overlay.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
input.value = '';
|
|
doSearch('');
|
|
setTimeout(function() { input.focus(); }, 50);
|
|
};
|
|
|
|
window.closeSearch = function() {
|
|
overlay.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
activeIdx = -1;
|
|
};
|
|
|
|
input.addEventListener('input', function() {
|
|
doSearch(input.value);
|
|
});
|
|
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (results.length) setActive(Math.min(activeIdx + 1, results.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (results.length) setActive(Math.max(activeIdx - 1, 0));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (results[activeIdx]) navigateTo(results[activeIdx].item.id);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeSearch();
|
|
}
|
|
});
|
|
|
|
// Ctrl+K / Cmd+K shortcut
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
if (overlay.classList.contains('open')) {
|
|
closeSearch();
|
|
} else {
|
|
openSearch();
|
|
}
|
|
}
|
|
// Also allow / to open search when not focused on input
|
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
|
e.preventDefault();
|
|
openSearch();
|
|
}
|
|
});
|
|
|
|
// Auto-open search when arriving from other pages via ?search=1
|
|
if (new URLSearchParams(window.location.search).get('search') === '1') {
|
|
history.replaceState(null, '', window.location.pathname + window.location.hash);
|
|
setTimeout(openSearch, 100);
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|