コンテンツにスキップ

プラグインの作成

このガイドでは、unified-live のプラットフォームプラグインを構築する手順を説明します。プラグインは配信プラットフォームの API を SDK の統一インターフェースに接続します。

プラグインを作成するタイミング

Section titled “プラグインを作成するタイミング”

新しい配信プラットフォーム(例: Kick、Bilibili、ニコニコ)のサポートを追加したい場合にプラグインを作成します。各プラグインは独立したパッケージで、1つのプラットフォームの API を統一された ContentChannelBroadcast 型にマッピングします。

プラグインは2つの部分で構成されます:

  1. PluginDefinition — 宣言的な設定: 名前、ベース URL、認証、レート制限、URL マッチング
  2. PluginMethodsRestManager を使ってプラットフォーム API を呼び出すデータアクセス関数

これらを PlatformPlugin.create(definition, methods) で組み合わせて、完全に配線されたプラグインを生成します。

PluginDefinition + PluginMethods
PlatformPlugin.create()
PlatformPlugin(UnifiedClient に登録可能)

matchUrl 関数は純粋関数(ネットワークコール不要)で、URL が対象プラットフォームに属するかを判定します:

import type { ResolvedUrl } from "@unified-live/core";
const matchExampleUrl = (url: string): ResolvedUrl | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "example.tv") return null;
// マッチ: https://example.tv/videos/12345
const videoMatch = parsed.pathname.match(/^\/videos\/(\w+)$/);
if (videoMatch) {
return { platform: "example", type: "content", id: videoMatch[1] };
}
// マッチ: https://example.tv/channels/username
const channelMatch = parsed.pathname.match(/^\/channels\/(\w+)$/);
if (channelMatch) {
return { platform: "example", type: "channel", id: channelMatch[1] };
}
return null;
} catch {
return null;
}
};

プラットフォーム固有の設定を PluginDefinition で定義します。これはファクトリ関数内で組み立てます(ステップ 4 参照):

import {
TokenManager,
createTokenBucketStrategy,
createRateLimitHeaderParser,
type PluginDefinition,
} from "@unified-live/core";
const parseHeaders = createRateLimitHeaderParser({
limit: "X-RateLimit-Limit",
remaining: "X-RateLimit-Remaining",
reset: "X-RateLimit-Reset",
});
const createDefinition = (config: {
apiKey: string;
fetch?: typeof globalThis.fetch;
}): PluginDefinition => ({
name: "example",
baseUrl: "https://api.example.tv/v1",
rateLimitStrategy: createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 }, // 100リクエスト/分
parseHeaders,
platform: "example",
}),
tokenManager: TokenManager.static(`Bearer ${config.apiKey}`),
matchUrl: matchExampleUrl,
fetch: config.fetch,
capabilities: {
supportsBroadcasts: true,
supportsArchiveResolution: false,
supportsBatchContent: false,
supportsBatchBroadcasts: false,
supportsSearch: false,
supportsClips: false,
authModel: "apiKey",
rateLimitModel: "tokenBucket",
},
});

PluginMethods を実装 — 各メソッドは RestManager を受け取り、統一型を返します:

import type {
RestManager,
PluginMethods,
Content,
Channel,
Broadcast,
Page,
Archive,
} from "@unified-live/core";
const exampleGetContent = async (rest: RestManager, id: string): Promise<Content> => {
const res = await rest.request<{ video: ExampleVideo }>({
method: "GET",
path: `/videos/${id}`,
bucketId: "videos:get",
});
return mapToContent(res.data.video);
};
const exampleGetChannel = async (rest: RestManager, id: string): Promise<Channel> => {
const res = await rest.request<{ channel: ExampleChannel }>({
method: "GET",
path: `/channels/${id}`,
bucketId: "channels:get",
});
return mapToChannel(res.data.channel);
};
const exampleListBroadcasts = async (
rest: RestManager,
channelId: string,
): Promise<Broadcast[]> => {
const res = await rest.request<{ streams: ExampleStream[] }>({
method: "GET",
path: `/channels/${channelId}/live`,
bucketId: "streams:list",
});
return res.data.streams.map(mapToBroadcast);
};
const exampleListArchives = async (
rest: RestManager,
channelId: string,
cursor?: string,
pageSize?: number,
options?: ArchiveListOptions,
): Promise<Page<Archive>> => {
const res = await rest.request<{ videos: ExampleVideo[]; nextCursor?: string }>({
method: "GET",
path: `/channels/${channelId}/videos`,
query: {
...(cursor && { cursor }),
...(pageSize && { limit: String(pageSize) }),
},
bucketId: "videos:list",
});
return {
items: res.data.videos.map(mapToArchive),
cursor: res.data.nextCursor,
hasMore: !!res.data.nextCursor,
};
};
const methods: PluginMethods = {
getContent: exampleGetContent,
getChannel: exampleGetChannel,
listBroadcasts: exampleListBroadcasts,
listArchives: exampleListArchives,
};

PlatformPlugin.create() で definition と methods を組み合わせます:

export const createExamplePlugin = (config: {
apiKey: string;
fetch?: typeof globalThis.fetch;
}): PlatformPlugin => {
return PlatformPlugin.create(createDefinition(config), methods);
};

SDK は TokenManager を通じて3つの認証パターンをサポート:

パターンユースケース
静的API キー / Basic authTokenManager.static("Bearer key123")
OAuth2トークンのリフレッシュが必要getAuthHeader() + invalidate() を実装(下記参照)
クエリパラメータURL 中の API キーtokenManager の代わりに transformRequest を使用

クエリパラメータ認証(YouTube のような場合)は transformRequest を使用:

const definition: PluginDefinition = {
// ...
transformRequest: (req) => ({
...req,
query: { ...req.query, key: config.apiKey },
}),
};

OAuth2 の場合はカスタム TokenManager を実装:

import { AuthenticationError, type TokenManager } from "@unified-live/core";
const createOAuth2TokenManager = (config: {
clientId: string;
clientSecret: string;
}): TokenManager => {
let token: string | null = null;
let expiresAt = 0;
return {
getAuthHeader: async () => {
if (!token || Date.now() > expiresAt) {
const res = await fetch("https://api.example.tv/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
}).toString(),
});
if (!res.ok)
throw new AuthenticationError("example", {
message: `Token endpoint returned ${res.status}`,
});
const data = await res.json();
token = data.access_token;
expiresAt = Date.now() + data.expires_in * 1000 * 0.9; // 90%で更新
}
return `Bearer ${token}`;
},
invalidate: () => {
token = null;
expiresAt = 0;
},
};
};

プラットフォームのモデルに応じて戦略を選択:

戦略使用タイミングプラットフォーム例
Token Bucket時間窓あたりの固定リクエスト数Twitch (800 req/min)、TwitCasting (60 req/min)
Quota Budgetコストベースの日次制限YouTube (10,000 units/day)

Token Bucket — リクエスト/秒制限のプラットフォーム向け:

import { createTokenBucketStrategy } from "@unified-live/core";
const strategy = createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 },
parseHeaders: myHeaderParser,
platform: "myplatform",
});

Quota Budget — 日次コストベース制限のプラットフォーム向け:

import { createQuotaBudgetStrategy } from "@unified-live/core";
const strategy = createQuotaBudgetStrategy({
dailyLimit: 10_000,
costMap: {
"videos:get": 1,
"channels:get": 1,
"search:list": 100,
},
defaultCost: 1,
platform: "example",
});
import { describe, it, expect } from "vitest";
describe("matchExampleUrl", () => {
it.each([
["https://example.tv/videos/123", { platform: "example", type: "content", id: "123" }],
["https://example.tv/channels/user1", { platform: "example", type: "channel", id: "user1" }],
["https://other.com/videos/123", null],
["not-a-url", null],
])("matchExampleUrl(%s) = %o", (url, expected) => {
expect(matchExampleUrl(url)).toEqual(expected);
});
});

モック fetch を使用して実際の API にアクセスせずにデータメソッドをテスト:

import { describe, it, expect, vi } from "vitest";
describe("createExamplePlugin", () => {
it("ID でコンテンツを取得", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ video: { id: "123", title: "Test" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const plugin = createExamplePlugin({
apiKey: "test-key",
fetch: mockFetch, // モック fetch を注入
});
const content = await plugin.getContent("123");
expect(content.title).toBe("Test");
});
});

最小限だが完全なプラグイン:

import {
PlatformPlugin,
TokenManager,
createTokenBucketStrategy,
type PluginDefinition,
type PluginMethods,
type RestManager,
type Content,
type Channel,
type Broadcast,
type Page,
type Archive,
type ResolvedUrl,
} from "@unified-live/core";
// URL マッチング
const matchUrl = (url: string): ResolvedUrl | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "example.tv") return null;
const match = parsed.pathname.match(/^\/videos\/(\w+)$/);
return match ? { platform: "example", type: "content", id: match[1] } : null;
} catch {
return null;
}
};
// データメソッド
const getContent = async (rest: RestManager, id: string): Promise<Content> => {
const res = await rest.request<any>({ method: "GET", path: `/videos/${id}` });
return res.data as Content; // プラットフォームレスポンスを Content にマッピング
};
const getChannel = async (rest: RestManager, id: string): Promise<Channel> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${id}` });
return res.data as Channel; // プラットフォームレスポンスを Channel にマッピング
};
const listBroadcasts = async (rest: RestManager, channelId: string): Promise<Broadcast[]> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${channelId}/live` });
return []; // res.data を Broadcast[] にマッピング
};
const listArchives = async (
rest: RestManager,
channelId: string,
cursor?: string,
pageSize?: number,
options?: ArchiveListOptions,
): Promise<Page<Archive>> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${channelId}/videos` });
return { items: [], hasMore: false }; // res.data をマッピング
};
// ファクトリ
export type ExamplePluginConfig = { apiKey: string; fetch?: typeof globalThis.fetch };
export const createExamplePlugin = (config: ExamplePluginConfig): PlatformPlugin => {
return PlatformPlugin.create(
{
name: "example",
baseUrl: "https://api.example.tv/v1",
rateLimitStrategy: createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 },
parseHeaders: () => undefined,
platform: "example",
}),
tokenManager: TokenManager.static(`Bearer ${config.apiKey}`),
matchUrl,
fetch: config.fetch,
capabilities: {
supportsBroadcasts: true,
supportsArchiveResolution: false,
supportsBatchContent: false,
supportsBatchBroadcasts: false,
supportsSearch: false,
supportsClips: false,
authModel: "apiKey",
rateLimitModel: "tokenBucket",
},
},
{ getContent, getChannel, listBroadcasts, listArchives },
);
};