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.

Thư viện client theo ngôn ngữ

XuniBOX không phát hành package trên npm hay Packagist — API đủ đơn giản để bạn giữ toàn quyền kiểm soát bằng một file duy nhất. Dưới đây là client tối giản nhưng đầy đủ cho từng nền tảng; copy vào dự án rồi sửa theo nhu cầu.

Các ví dụ server-side (TypeScript, Python, PHP, Go, .NET) gắn X-API-Key trực tiếp. Các ví dụ mobile (Kotlin, Swift) trỏ về backend proxy của bạn vì app phát hành ra cửa hàng có thể bị dịch ngược để lấy key.

TypeScript / Node.js

Chỉ dùng fetch chuẩn nên chạy được trên Node 18+, Bun, Deno, Cloudflare Workers và Vercel Edge mà không cần cài thêm gì.

TypeScript — xunibox.ts
// xunibox.ts — client gọn cho Node 18+, Deno, Bun, Cloudflare Workers.
export type XuniBoxTrack = {
  id: string; title: string; artist: string; artist_id: string | null;
  genre: string | null; duration: string;
  audio_url: string; cover_url: string | null;
  preview_duration_seconds: number; media_expires_in: number;
  play_count: number;
};

export type Paged<T> = { data: T[]; meta: { total: number; page: number; limit: number; total_pages: number } };

export class XuniBox {
  constructor(
    private readonly apiKey: string,
    private readonly base = "https://xunibox.vn/api/public",
  ) {}

  private async get<T>(path: string, query: Record<string, string | number | undefined> = {}): Promise<T> {
    const search = new URLSearchParams();
    for (const [key, value] of Object.entries(query)) {
      if (value !== undefined && value !== "") search.set(key, String(value));
    }
    const url = `${this.base}${path}${search.size ? `?${search}` : ""}`;
    const res = await fetch(url, { headers: { "X-API-Key": this.apiKey, Accept: "application/json" } });
    if (!res.ok) throw new XuniBoxError(res.status, await res.text());
    return res.json() as Promise<T>;
  }

  tracks(query: { q?: string; genre?: string; artist_id?: string; sort?: string; page?: number; limit?: number } = {}) {
    return this.get<Paged<XuniBoxTrack>>("/tracks", query);
  }
  track(id: string)        { return this.get<XuniBoxTrack>(`/tracks/${id}`); }
  audioUrl(id: string)     { return this.get<{ audio_url: string; expires_at: string }>(`/tracks/${id}/audio-url`); }
  waveform(id: string, points = 120) { return this.get(`/tracks/${id}/waveform`, { points }); }
  similar(id: string, limit = 10)    { return this.get(`/tracks/${id}/similar`, { limit }); }
  search(q: string, limit = 20)      { return this.get("/search", { q, limit }); }
  suggest(q: string, limit = 8)      { return this.get("/search/suggest", { q, limit }); }
  trending(days = 7, limit = 20)     { return this.get("/trending", { days, limit }); }
  genres()                 { return this.get("/genres"); }
  artists(q?: string)      { return this.get("/artists", { q }); }
  usage(days = 30)         { return this.get("/me/usage", { days }); }
}

export class XuniBoxError extends Error {
  constructor(readonly status: number, readonly body: string) {
    super(`XuniBOX API ${status}: ${body}`);
  }
}

Python

Python — xunibox.py
# xunibox.py — client đồng bộ dùng requests
import os
from dataclasses import dataclass
from typing import Any

import requests


@dataclass
class XuniBox:
    api_key: str = os.environ.get("XUNIBOX_API_KEY", "")
    base: str = "https://xunibox.vn/api/public"

    def __post_init__(self) -> None:
        self._session = requests.Session()
        self._session.headers.update({"X-API-Key": self.api_key, "Accept": "application/json"})

    def _get(self, path: str, **params: Any) -> dict:
        params = {k: v for k, v in params.items() if v is not None}
        res = self._session.get(f"{self.base}{path}", params=params, timeout=10)
        res.raise_for_status()
        return res.json()

    def tracks(self, **q):            return self._get("/tracks", **q)
    def track(self, track_id: str):   return self._get(f"/tracks/{track_id}")
    def audio_url(self, track_id):    return self._get(f"/tracks/{track_id}/audio-url")
    def search(self, q, limit=20):    return self._get("/search", q=q, limit=limit)
    def trending(self, days=7):       return self._get("/trending", days=days)
    def genres(self):                 return self._get("/genres")

    def iter_all_tracks(self, **q):
        """Duyệt hết kho nhạc, tự phân trang."""
        page = 1
        while True:
            payload = self.tracks(page=page, limit=100, **q)
            yield from payload["data"]
            if page >= payload["meta"]["total_pages"]:
                break
            page += 1


if __name__ == "__main__":
    client = XuniBox()
    for track in client.iter_all_tracks(genre="Indie"):
        print(track["id"], track["title"], track["artist"])

PHP / Laravel

PHP — App\\Services\\XuniBox
<?php
// app/Services/XuniBox.php — Laravel service, đăng ký vào container.
namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class XuniBox
{
    private const BASE = 'https://xunibox.vn/api/public';

    private function client()
    {
        return Http::withHeaders(['X-API-Key' => config('services.xunibox.key')])
            ->acceptJson()
            ->timeout(10)
            ->retry(3, 500);
    }

    /** Danh sách bài hát, cache 5 phút để tiết kiệm hạn mức. */
    public function tracks(array $query = []): array
    {
        $cacheKey = 'xunibox:tracks:' . md5(json_encode($query));

        return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($query) {
            return $this->client()->get(self::BASE . '/tracks', $query)->throw()->json();
        });
    }

    /** Link nghe thử — KHÔNG cache, chỉ sống 1 giờ. */
    public function audioUrl(string $trackId): array
    {
        return $this->client()->get(self::BASE . "/tracks/{$trackId}/audio-url")->throw()->json();
    }

    public function search(string $q, int $limit = 20): array
    {
        return $this->client()->get(self::BASE . '/search', compact('q', 'limit'))->throw()->json();
    }
}

Go

Go — xunibox.go
// xunibox.go
package xunibox

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"
)

type Client struct {
	APIKey string
	Base   string
	HTTP   *http.Client
}

func New(apiKey string) *Client {
	return &Client{
		APIKey: apiKey,
		Base:   "https://xunibox.vn/api/public",
		HTTP:   &http.Client{Timeout: 10 * time.Second},
	}
}

type Track struct {
	ID       string `json:"id"`
	Title    string `json:"title"`
	Artist   string `json:"artist"`
	Genre    string `json:"genre"`
	Duration string `json:"duration"`
	AudioURL string `json:"audio_url"`
}

type TracksResponse struct {
	Data []Track `json:"data"`
	Meta struct {
		Total      int `json:"total"`
		Page       int `json:"page"`
		TotalPages int `json:"total_pages"`
	} `json:"meta"`
}

func (c *Client) Tracks(ctx context.Context, query url.Values) (*TracksResponse, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/tracks?"+query.Encode(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-API-Key", c.APIKey)
	req.Header.Set("Accept", "application/json")

	res, err := c.HTTP.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("xunibox: HTTP %d (còn %s request trong phút này)",
			res.StatusCode, res.Header.Get("X-RateLimit-Remaining"))
	}

	var out TracksResponse
	return &out, json.NewDecoder(res.Body).Decode(&out)
}

Kotlin / Android

Kotlin — Retrofit
// XuniBoxApi.kt — Android / Kotlin với Retrofit.
// LƯU Ý: app mobile KHÔNG được chứa API key. Trỏ BASE_URL về backend proxy của bạn.
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

data class Track(
    val id: String,
    val title: String,
    val artist: String,
    val genre: String?,
    val duration: String,
    val cover_url: String?,
)

data class Meta(val total: Int, val page: Int, val limit: Int, val total_pages: Int)
data class TracksResponse(val data: List<Track>, val meta: Meta)
data class AudioUrl(val audio_url: String, val media_expires_in: Int, val expires_at: String)

interface XuniBoxApi {
    @GET("tracks")
    suspend fun tracks(
        @Query("q") q: String? = null,
        @Query("genre") genre: String? = null,
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 20,
    ): TracksResponse

    @GET("tracks/{id}/audio-url")
    suspend fun audioUrl(@Path("id") id: String): AudioUrl
}

val api: XuniBoxApi = Retrofit.Builder()
    .baseUrl("https://your-app.com/api/xunibox/")   // proxy của bạn
    .addConverterFactory(MoshiConverterFactory.create())
    .build()
    .create(XuniBoxApi::class.java)

Swift / iOS

Swift — async/await
// XuniBox.swift — iOS / macOS, async/await.
// App iOS cũng không nên chứa API key: gọi qua backend proxy của bạn.
import Foundation

struct Track: Decodable {
    let id: String
    let title: String
    let artist: String
    let genre: String?
    let duration: String
    let coverUrl: String?

    enum CodingKeys: String, CodingKey {
        case id, title, artist, genre, duration
        case coverUrl = "cover_url"
    }
}

struct Meta: Decodable { let total, page, limit, totalPages: Int
    enum CodingKeys: String, CodingKey { case total, page, limit, totalPages = "total_pages" } }

struct TracksResponse: Decodable { let data: [Track]; let meta: Meta }

enum XuniBox {
    static let base = URL(string: "https://your-app.com/api/xunibox")!

    static func tracks(query: String? = nil, page: Int = 1) async throws -> TracksResponse {
        var components = URLComponents(url: base.appendingPathComponent("tracks"), resolvingAgainstBaseURL: false)!
        components.queryItems = [
            query.map { URLQueryItem(name: "q", value: $0) },
            URLQueryItem(name: "page", value: String(page)),
        ].compactMap { $0 }

        let (data, response) = try await URLSession.shared.data(from: components.url!)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode(TracksResponse.self, from: data)
    }
}

C# / .NET

C# — IHttpClientFactory
// XuniBoxClient.cs — .NET 8, đăng ký bằng IHttpClientFactory.
using System.Net.Http.Json;
using System.Text.Json.Serialization;

public sealed record Track(
    string Id,
    string Title,
    string Artist,
    string? Genre,
    string Duration,
    [property: JsonPropertyName("cover_url")] string? CoverUrl);

public sealed record Meta(int Total, int Page, int Limit, [property: JsonPropertyName("total_pages")] int TotalPages);
public sealed record TracksResponse(IReadOnlyList<Track> Data, Meta Meta);

public sealed class XuniBoxClient(HttpClient http)
{
    public async Task<TracksResponse> TracksAsync(string? q = null, int page = 1, int limit = 20, CancellationToken ct = default)
    {
        var url = $"tracks?page={page}&limit={limit}" + (q is null ? "" : $"&q={Uri.EscapeDataString(q)}");
        var response = await http.GetAsync(url, ct);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<TracksResponse>(cancellationToken: ct))!;
    }
}

// Program.cs
builder.Services.AddHttpClient<XuniBoxClient>(client =>
{
    client.BaseAddress = new Uri("https://xunibox.vn/api/public/");
    client.DefaultRequestHeaders.Add("X-API-Key", builder.Configuration["XuniBox:ApiKey"]);
    client.Timeout = TimeSpan.FromSeconds(10);
});

Tự sinh SDK từ OpenAPI

Nếu bạn muốn client có kiểu dữ liệu đầy đủ và tự cập nhật theo phiên bản đặc tả, hãy sinh code từ file OpenAPI thay vì viết tay.

Sinh SDK từ đặc tả
# Tải đặc tả (không cần API key)
curl -sSL https://xunibox.vn/api/public/openapi.v1.json -o xunibox.openapi.v1.json

# Sinh SDK TypeScript từ đặc tả
npx openapi-typescript xunibox.openapi.v1.json -o xunibox-api.d.ts
Ngôn ngữCông cụ gợi ýLệnh
TypeScript (kiểu dữ liệu)openapi-typescriptnpx openapi-typescript xunibox.openapi.v1.json -o xunibox-api.d.ts
TypeScript (client đầy đủ)openapi-generatornpx @openapitools/openapi-generator-cli generate -i xunibox.openapi.v1.json -g typescript-fetch -o ./xunibox-sdk
Pythonopenapi-generatoropenapi-generator-cli generate -i xunibox.openapi.v1.json -g python -o ./xunibox_sdk
PHPopenapi-generatoropenapi-generator-cli generate -i xunibox.openapi.v1.json -g php -o ./xunibox-sdk
Gooapi-codegenoapi-codegen -package xunibox xunibox.openapi.v1.json > xunibox.gen.go
Dart / Flutteropenapi-generatoropenapi-generator-cli generate -i xunibox.openapi.v1.json -g dart-dio -o ./xunibox_sdk
Mọi client dù viết tay hay tự sinh đều nên thoả ba điều kiện: timeout tối đa 10 giây, retry có backoff cho lỗi 5xx và 429, và không bao giờ lưu audio_url vào cơ sở dữ liệu.

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.

Xin quyền tích hợp