Tài liệu API XuniBOX
API chỉ đọc để lấy danh sách nhạc, nghệ sĩ và thể loại từ kho XuniBOX. Mỗi chủ đề dưới đây là một trang riêng, có đường dẫn riêng để bạn chia sẻ cho đội kỹ thuật.
Nhúng player nghe thử
XuniBOX không cung cấp iframe nhúng sẵn — bạn tự dựng player để giao diện khớp hoàn toàn với sản phẩm của mình. Mọi ví dụ dưới đây đều theo cùng ba nguyên tắc: giấu API key ở backend, lấy link media tươi ngay trước khi phát, và chỉ lưu track id vào cơ sở dữ liệu của bạn.
X-API-Key, key sẽ lộ trong tab Network hoặc trong file APK/IPA. Hãy đặt một endpoint trung gian trên máy chủ của bạn.Bước 0 — Backend proxy
Proxy chỉ mở đúng những endpoint bạn cần, gắn key ở phía server và quyết định chính sách cache. Ví dụ dùng Next.js, nhưng nguyên tắc giống hệt với Express, Laravel, Django hay Spring.
// Backend proxy — BẮT BUỘC nếu player chạy trên trình duyệt.
// Next.js App Router: app/api/xunibox/[...path]/route.ts
export const runtime = "nodejs";
const ALLOWED = [
/^tracks$/,
/^tracks\/[0-9a-f-]{36}$/,
/^tracks\/[0-9a-f-]{36}\/audio-url$/,
/^tracks\/[0-9a-f-]{36}\/waveform$/,
/^genres$/,
/^artists$/,
/^search$/,
];
export async function GET(request: Request, { params }: { params: { path: string[] } }) {
const path = params.path.join("/");
if (!ALLOWED.some((rule) => rule.test(path))) {
return Response.json({ error: "Endpoint không được phép." }, { status: 403 });
}
const search = new URL(request.url).search;
const upstream = await fetch(`https://xunibox.vn/api/public/${path}${search}`, {
headers: { "X-API-Key": process.env.XUNIBOX_API_KEY!, Accept: "application/json" },
// audio-url không được cache; danh sách thì cache 60 giây ở edge.
next: path.endsWith("/audio-url") ? { revalidate: 0 } : { revalidate: 60 },
});
const body = await upstream.text();
return new Response(body, {
status: upstream.status,
headers: {
"Content-Type": "application/json; charset=utf-8",
// Chuyển tiếp thông tin hạn mức để frontend của bạn cũng thấy được.
"X-RateLimit-Remaining": upstream.headers.get("X-RateLimit-Remaining") ?? "",
"Cache-Control": path.endsWith("/audio-url") ? "no-store" : "public, max-age=60",
},
});
}Player HTML thuần
Không cần build tool, dán thẳng vào trang là chạy. Phù hợp cho landing page, WordPress hoặc trang tĩnh.
<!-- Player nghe thử tối giản: HTML + JS thuần, không cần thư viện. -->
<div class="xb-player" data-track-id="550e8400-e29b-41d4-a716-446655440000">
<img class="xb-cover" alt="" width="56" height="56" />
<div class="xb-meta">
<strong class="xb-title">Đang tải…</strong>
<span class="xb-artist"></span>
</div>
<button class="xb-play" type="button" aria-label="Phát nhạc">▶</button>
<audio class="xb-audio" preload="none" controlsList="nodownload"></audio>
</div>
<script type="module">
// QUAN TRỌNG: gọi qua backend của bạn (/api/xunibox/...) để không lộ API key.
async function mount(el) {
const id = el.dataset.trackId;
const track = await fetch(`/api/xunibox/tracks/${id}`).then((r) => r.json());
el.querySelector(".xb-cover").src = track.cover_url ?? "";
el.querySelector(".xb-title").textContent = track.title;
el.querySelector(".xb-artist").textContent = track.artist;
const audio = el.querySelector(".xb-audio");
const button = el.querySelector(".xb-play");
button.addEventListener("click", async () => {
if (!audio.paused) { audio.pause(); button.textContent = "▶"; return; }
// Link media chỉ sống 1 giờ — luôn lấy link tươi ngay trước khi phát.
const fresh = await fetch(`/api/xunibox/tracks/${id}/audio-url`).then((r) => r.json());
audio.src = fresh.audio_url;
await audio.play();
button.textContent = "❚❚";
});
audio.addEventListener("ended", () => { button.textContent = "▶"; });
// Đoạn nghe thử tự dừng sau preview_duration_seconds giây (15 / 30 / 60).
}
document.querySelectorAll(".xb-player").forEach(mount);
</script>
<style>
.xb-player { display: flex; align-items: center; gap: 12px; padding: 12px;
border: 1px solid #2a2a2a; border-radius: 12px; background: #141414; color: #fff; }
.xb-cover { border-radius: 8px; object-fit: cover; }
.xb-meta { display: flex; flex-direction: column; min-width: 0; }
.xb-play { margin-left: auto; width: 40px; height: 40px; border: 0; border-radius: 999px;
background: linear-gradient(135deg, #ff4d2d, #ff9425); color: #fff; cursor: pointer; }
.xb-audio { display: none; }
</style>Component React
// components/XuniBoxPlayer.tsx — React 18/19, không phụ thuộc thư viện ngoài.
import { useEffect, useRef, useState } from "react";
type Track = {
id: string; title: string; artist: string;
cover_url: string | null; preview_duration_seconds: number;
};
export function XuniBoxPlayer({ trackId }: { trackId: string }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [track, setTrack] = useState<Track | null>(null);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
let alive = true;
// Endpoint proxy của BẠN, đã gắn sẵn X-API-Key ở phía server.
fetch(`/api/xunibox/tracks/${trackId}`)
.then((r) => r.json())
.then((data) => { if (alive) setTrack(data); });
return () => { alive = false; };
}, [trackId]);
async function toggle() {
const audio = audioRef.current;
if (!audio) return;
if (playing) { audio.pause(); setPlaying(false); return; }
setLoading(true);
try {
const fresh = await fetch(`/api/xunibox/tracks/${trackId}/audio-url`).then((r) => r.json());
audio.src = fresh.audio_url; // link ký hạn, hết hạn sau media_expires_in giây
await audio.play();
setPlaying(true);
} finally {
setLoading(false);
}
}
if (!track) return <div className="h-20 animate-pulse rounded-xl bg-neutral-800" />;
return (
<div className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
{track.cover_url && <img src={track.cover_url} alt="" className="size-14 rounded-lg object-cover" />}
<div className="min-w-0">
<p className="truncate font-semibold text-white">{track.title}</p>
<p className="truncate text-sm text-neutral-400">{track.artist}</p>
</div>
<button
onClick={toggle}
disabled={loading}
aria-label={playing ? "Tạm dừng" : "Phát nhạc"}
className="ml-auto size-10 rounded-full bg-gradient-to-br from-[#ff4d2d] to-[#ff9425] text-white"
>
{loading ? "…" : playing ? "❚❚" : "▶"}
</button>
<audio
ref={audioRef}
preload="none"
controlsList="nodownload"
onEnded={() => setPlaying(false)}
onPause={() => setPlaying(false)}
/>
</div>
);
}Web Component dùng chung mọi framework
Đăng ký một lần rồi dùng thẻ <xunibox-player track-id="…"> ở Vue, Svelte, Angular, Rails hay HTML tĩnh.
// <xunibox-player track-id="..."></xunibox-player>
// Web Component dùng được trong mọi framework, kể cả WordPress hay HTML tĩnh.
class XuniBoxPlayerElement extends HTMLElement {
static observedAttributes = ["track-id"];
connectedCallback() {
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; font-family: system-ui, sans-serif; }
button { width: 100%; padding: 12px; border: 0; border-radius: 10px; color: #fff;
background: linear-gradient(135deg,#ff4d2d,#ff9425); cursor: pointer; }
</style>
<button part="button">▶ Nghe thử</button>
<audio preload="none" controlsList="nodownload"></audio>`;
const audio = this.shadowRoot.querySelector("audio");
const button = this.shadowRoot.querySelector("button");
button.addEventListener("click", async () => {
if (!audio.paused) { audio.pause(); button.textContent = "▶ Nghe thử"; return; }
const id = this.getAttribute("track-id");
const fresh = await fetch(`/api/xunibox/tracks/${id}/audio-url`).then((r) => r.json());
audio.src = fresh.audio_url;
await audio.play();
button.textContent = "❚❚ Đang phát";
// Phát sự kiện để trang chủ quản của bạn ghi analytics.
this.dispatchEvent(new CustomEvent("xunibox:play", { detail: { id }, bubbles: true }));
});
audio.addEventListener("ended", () => { button.textContent = "▶ Nghe thử"; });
}
}
customElements.define("xunibox-player", XuniBoxPlayerElement);Vẽ sóng âm bằng canvas
Endpoint /tracks/:id/waveform trả sẵn mảng peaks thang 0–100 nên bạn vẽ được sóng âm mà không phải tải và phân tích file nhạc phía client. Tham số points nhận 16–512, mặc định 120.
// Vẽ sóng âm bằng canvas từ /tracks/:id/waveform — không cần tải file nhạc.
async function drawWaveform(canvas, trackId, points = 120) {
const { peaks, analysis, duration } = await fetch(
`/api/xunibox/tracks/${trackId}/waveform?points=${points}`,
).then((r) => r.json());
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
const barWidth = width / peaks.length;
ctx.clearRect(0, 0, width, height);
peaks.forEach((peak, index) => { // peak nằm trong thang 0-100
const barHeight = Math.max(2, (peak / 100) * height);
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, "#ff4d2d");
gradient.addColorStop(1, "#ff9425");
ctx.fillStyle = gradient;
ctx.fillRect(index * barWidth, (height - barHeight) / 2, barWidth * 0.7, barHeight);
});
console.log(duration, analysis.bpm, analysis.musical_key); // "3:24", 128, "A minor"
}Mobile: Flutter
// Flutter — dùng package just_audio cho phần nghe thử trong app mobile.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:just_audio/just_audio.dart';
class XuniBoxPreview {
final _player = AudioPlayer();
final String proxyBase; // backend của bạn, KHÔNG nhúng API key vào app
XuniBoxPreview(this.proxyBase);
Future<void> play(String trackId) async {
// Link media chỉ sống 1 giờ nên phải lấy mới mỗi lần bấm phát.
final res = await http.get(Uri.parse('$proxyBase/tracks/$trackId/audio-url'));
if (res.statusCode != 200) throw Exception('Không lấy được link nhạc');
final data = jsonDecode(res.body) as Map<String, dynamic>;
await _player.setUrl(data['audio_url'] as String);
await _player.play();
}
Future<void> stop() => _player.stop();
void dispose() => _player.dispose();
}Những cái bẫy thường gặp
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Nhạc phát được lúc test, sáng hôm sau lỗi 403 | Bạn lưu audio_url vào DB. Link ký hạn đã hết hạn. | Chỉ lưu id, gọi /tracks/:id/audio-url ngay trước khi phát. |
| Trình duyệt chặn không cho phát | Chính sách autoplay: audio chỉ được phát sau một thao tác của người dùng. | Gọi audio.play() bên trong sự kiện click, không gọi trong useEffect. |
| iOS Safari không phát | Safari yêu cầu gán src và play() trong cùng một cử chỉ chạm. | Gọi audio.play() ngay, gán src trước đó hoặc dùng một audio element dùng lại nhiều lần. |
| Nhạc dừng sớm hơn dự kiến | audio_url là đoạn preview 15/30/60 giây, không phải bản đầy đủ. | Đọc preview_duration_seconds để hiển thị đúng thời lượng thanh tiến trình. |
| Người dùng tải được file về | Trình duyệt hiện nút tải trong thanh điều khiển mặc định. | Dùng controlsList="nodownload", ẩn thẻ audio và tự dựng nút phát như ví dụ trên. |
| Ảnh bìa lỗi sau một giờ | cover_url cũng là link ký hạn. | Lấy lại cùng lúc với audio-url, hoặc tải ảnh về CDN của bạn khi đồng bộ. |
Sẵn sàng tích hợp?
Gửi thông tin sản phẩm để admin XuniBOX xét duyệt và cấp API key.