feat(service-integrations): phases 9-10 — media integrations + Planka

- Emby: now playing, library stats, recently added, active streams
- Immich: library stats, recent uploads with formatted storage
- Deluge: active torrents with progress, transfer speed, disk space gauge
- MeTube: download queue progress (no auth required)
- Planka: my cards, overdue cards with red badges, board summary
- All 11 integrations registered in registry
This commit is contained in:
2026-03-25 22:16:27 +03:00
parent d73fb9c680
commit 55e220bc07
21 changed files with 1227 additions and 0 deletions
@@ -0,0 +1,103 @@
import { fetchWithTimeout } from '../base.js';
export interface DelugeTorrent {
readonly name: string;
readonly progress: number;
readonly state: string;
readonly download_payload_rate: number;
readonly upload_payload_rate: number;
readonly eta: number;
readonly total_size: number;
}
export interface DelugeUIResponse {
readonly result: {
readonly torrents: Record<string, DelugeTorrent>;
readonly stats: {
readonly download_rate: number;
readonly upload_rate: number;
};
};
}
export interface DelugeFreeSpaceResponse {
readonly result: number;
}
function rpcUrl(appUrl: string): string {
return `${appUrl.replace(/\/$/, '')}/json`;
}
async function rpcCall(
url: string,
method: string,
params: unknown[],
id: number,
cookie?: string
): Promise<Response> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (cookie) {
headers['Cookie'] = cookie;
}
const res = await fetchWithTimeout(url, {
method: 'POST',
headers,
body: JSON.stringify({ method, params, id })
});
if (!res.ok) {
throw new Error(`Deluge API returned ${res.status}`);
}
return res;
}
function extractCookie(res: Response): string {
const setCookie = res.headers.get('set-cookie');
if (!setCookie) return '';
const match = setCookie.match(/^([^;]+)/);
return match ? match[1] : '';
}
export async function authenticate(appUrl: string, password: string): Promise<string> {
const url = rpcUrl(appUrl);
const res = await rpcCall(url, 'auth.login', [password], 1);
const body = await res.json();
if (!body.result) {
throw new Error('Deluge authentication failed — invalid password');
}
const cookie = extractCookie(res);
if (!cookie) {
throw new Error('Deluge did not return a session cookie');
}
return cookie;
}
export async function fetchUIData(
appUrl: string,
cookie: string
): Promise<DelugeUIResponse> {
const url = rpcUrl(appUrl);
const fields = [
'name',
'progress',
'state',
'download_payload_rate',
'upload_payload_rate',
'eta',
'total_size'
];
const res = await rpcCall(url, 'web.update_ui', [fields, {}], 2, cookie);
return res.json();
}
export async function fetchFreeSpace(
appUrl: string,
cookie: string,
path = '/'
): Promise<DelugeFreeSpaceResponse> {
const url = rpcUrl(appUrl);
const res = await rpcCall(url, 'core.get_free_space', [path], 3, cookie);
return res.json();
}
+100
View File
@@ -0,0 +1,100 @@
import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js';
import { wrapError } from '../base.js';
import { delugeAuthConfigSchema } from './schema.js';
import { authenticate, fetchUIData, fetchFreeSpace } from './client.js';
import { toActiveTorrents, toTransferSpeed, toDiskSpace } from './transform.js';
const endpoints: readonly IntegrationEndpoint[] = [
{
id: 'active-torrents',
name: 'Active Torrents',
description: 'Active downloads with progress and speed',
renderer: 'progress',
refreshInterval: 10
},
{
id: 'transfer-speed',
name: 'Transfer Speed',
description: 'Total download and upload speed',
renderer: 'stat-card',
refreshInterval: 10
},
{
id: 'disk-space',
name: 'Disk Space',
description: 'Free disk space on download volume',
renderer: 'gauge',
refreshInterval: 300
}
] as const;
async function testConnection(
appUrl: string,
config: Record<string, unknown>
): Promise<IntegrationTestResult> {
try {
const { password } = delugeAuthConfigSchema.parse(config);
await authenticate(appUrl, password);
return { success: true, message: 'Connected to Deluge' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message: `Failed to connect to Deluge: ${message}` };
}
}
async function fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData> {
const { password } = delugeAuthConfigSchema.parse(config);
try {
const cookie = await authenticate(appUrl, password);
switch (endpointId) {
case 'active-torrents': {
const ui = await fetchUIData(appUrl, cookie);
return {
endpointId,
renderer: 'progress',
data: toActiveTorrents(ui.result.torrents),
fetchedAt: new Date().toISOString()
};
}
case 'transfer-speed': {
const ui = await fetchUIData(appUrl, cookie);
return {
endpointId,
renderer: 'stat-card',
data: toTransferSpeed(ui.result.stats),
fetchedAt: new Date().toISOString()
};
}
case 'disk-space': {
const space = await fetchFreeSpace(appUrl, cookie);
return {
endpointId,
renderer: 'gauge',
data: toDiskSpace(space.result),
fetchedAt: new Date().toISOString()
};
}
default:
throw new Error(`Unknown endpoint: ${endpointId}`);
}
} catch (error) {
throw wrapError('deluge', endpointId, error);
}
}
export const delugeIntegration: Integration = {
id: 'deluge',
name: 'Deluge',
icon: 'deluge',
description: 'BitTorrent client with download progress and transfer speeds',
authConfigSchema: delugeAuthConfigSchema,
endpoints,
testConnection,
fetchData
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const delugeAuthConfigSchema = z.object({
password: z.string().min(1, 'Password is required')
});
export type DelugeAuthConfig = z.infer<typeof delugeAuthConfigSchema>;
@@ -0,0 +1,67 @@
import type { StatCardData, GaugeData, ProgressData } from '../types.js';
import type { DelugeTorrent } from './client.js';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, exponent);
return `${value.toFixed(exponent > 0 ? 1 : 0)} ${units[exponent]}`;
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec === 0) return '0 B/s';
const formatted = formatBytes(bytesPerSec);
return `${formatted}/s`;
}
export function toActiveTorrents(
torrents: Record<string, DelugeTorrent>
): ProgressData {
const entries = Object.entries(torrents);
const active = entries.filter(
([, t]) => t.state === 'Downloading' || t.state === 'Seeding'
);
return {
items: active.map(([id, torrent]) => ({
id,
label: torrent.name,
progress: Math.round(torrent.progress),
subtitle: torrent.state,
speed:
torrent.download_payload_rate > 0
? formatSpeed(torrent.download_payload_rate)
: undefined
}))
};
}
export function toTransferSpeed(stats: {
readonly download_rate: number;
readonly upload_rate: number;
}): StatCardData {
return {
label: 'Download Speed',
value: formatSpeed(stats.download_rate),
subtitle: `Upload: ${formatSpeed(stats.upload_rate)}`
};
}
export function toDiskSpace(
freeBytes: number,
totalEstimate = 0
): GaugeData {
// If we have a total estimate, compute percentage; otherwise show raw free space
const totalBytes = totalEstimate > 0 ? totalEstimate : freeBytes * 2;
const usedBytes = totalBytes - freeBytes;
const usedPercent = Math.round((usedBytes / totalBytes) * 100);
return {
value: usedPercent,
max: 100,
label: `${formatBytes(freeBytes)} free`,
unit: '%',
thresholds: { warning: 75, critical: 90 }
};
}
@@ -0,0 +1,82 @@
import { fetchWithTimeout } from '../base.js';
export interface EmbySession {
readonly Id: string;
readonly UserName: string;
readonly NowPlayingItem?: {
readonly Name: string;
readonly Type: string;
readonly SeriesName?: string;
};
readonly PlayState?: {
readonly PlayMethod?: string;
};
}
export interface EmbyItemCounts {
readonly MovieCount: number;
readonly SeriesCount: number;
readonly EpisodeCount: number;
readonly ArtistCount: number;
readonly SongCount: number;
readonly AlbumCount: number;
}
export interface EmbyLatestItem {
readonly Id: string;
readonly Name: string;
readonly Type: string;
readonly DateCreated: string;
readonly SeriesName?: string;
}
export interface EmbySystemInfo {
readonly ServerName: string;
readonly Version: string;
}
function buildUrl(appUrl: string, path: string, apiKey: string, extra = ''): string {
const base = appUrl.replace(/\/$/, '');
const sep = extra ? `&${extra}` : '';
return `${base}/emby/${path}?api_key=${apiKey}${sep}`;
}
export async function fetchSessions(appUrl: string, apiKey: string): Promise<readonly EmbySession[]> {
const url = buildUrl(appUrl, 'Sessions', apiKey);
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Emby API returned ${res.status}`);
}
return res.json();
}
export async function fetchItemCounts(appUrl: string, apiKey: string): Promise<EmbyItemCounts> {
const url = buildUrl(appUrl, 'Items/Counts', apiKey);
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Emby API returned ${res.status}`);
}
return res.json();
}
export async function fetchLatestItems(
appUrl: string,
apiKey: string,
limit = 10
): Promise<readonly EmbyLatestItem[]> {
const url = buildUrl(appUrl, 'Items/Latest', apiKey, `Limit=${limit}`);
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Emby API returned ${res.status}`);
}
return res.json();
}
export async function fetchSystemInfo(appUrl: string, apiKey: string): Promise<EmbySystemInfo> {
const url = buildUrl(appUrl, 'System/Info', apiKey);
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Emby API returned ${res.status}`);
}
return res.json();
}
+117
View File
@@ -0,0 +1,117 @@
import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js';
import { wrapError } from '../base.js';
import { embyAuthConfigSchema } from './schema.js';
import { fetchSessions, fetchItemCounts, fetchLatestItems, fetchSystemInfo } from './client.js';
import { toNowPlaying, toLibraryStats, toRecentlyAdded, toActiveStreams } from './transform.js';
const endpoints: readonly IntegrationEndpoint[] = [
{
id: 'now-playing',
name: 'Now Playing',
description: 'Active sessions with user, title, and play method',
renderer: 'list',
refreshInterval: 15
},
{
id: 'library-stats',
name: 'Library Stats',
description: 'Total movies, series, episodes, and music counts',
renderer: 'stat-card',
refreshInterval: 3600
},
{
id: 'recently-added',
name: 'Recently Added',
description: 'Latest media added to the library',
renderer: 'list',
refreshInterval: 300
},
{
id: 'active-streams',
name: 'Active Streams',
description: 'Current active stream count',
renderer: 'stat-card',
refreshInterval: 15
}
] as const;
async function testConnection(
appUrl: string,
config: Record<string, unknown>
): Promise<IntegrationTestResult> {
try {
const { apiKey } = embyAuthConfigSchema.parse(config);
const info = await fetchSystemInfo(appUrl, apiKey);
return {
success: true,
message: `Connected to ${info.ServerName} (Emby ${info.Version})`
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message: `Failed to connect to Emby: ${message}` };
}
}
async function fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData> {
const { apiKey } = embyAuthConfigSchema.parse(config);
try {
switch (endpointId) {
case 'now-playing': {
const sessions = await fetchSessions(appUrl, apiKey);
return {
endpointId,
renderer: 'list',
data: toNowPlaying(sessions),
fetchedAt: new Date().toISOString()
};
}
case 'library-stats': {
const counts = await fetchItemCounts(appUrl, apiKey);
return {
endpointId,
renderer: 'stat-card',
data: toLibraryStats(counts),
fetchedAt: new Date().toISOString()
};
}
case 'recently-added': {
const items = await fetchLatestItems(appUrl, apiKey);
return {
endpointId,
renderer: 'list',
data: toRecentlyAdded(items),
fetchedAt: new Date().toISOString()
};
}
case 'active-streams': {
const sessions = await fetchSessions(appUrl, apiKey);
return {
endpointId,
renderer: 'stat-card',
data: toActiveStreams(sessions),
fetchedAt: new Date().toISOString()
};
}
default:
throw new Error(`Unknown endpoint: ${endpointId}`);
}
} catch (error) {
throw wrapError('emby', endpointId, error);
}
}
export const embyIntegration: Integration = {
id: 'emby',
name: 'Emby',
icon: 'emby',
description: 'Media server with streaming sessions and library statistics',
authConfigSchema: embyAuthConfigSchema,
endpoints,
testConnection,
fetchData
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const embyAuthConfigSchema = z.object({
apiKey: z.string().min(1, 'API key is required')
});
export type EmbyAuthConfig = z.infer<typeof embyAuthConfigSchema>;
@@ -0,0 +1,84 @@
import type { StatCardData, ListData } from '../types.js';
import type { EmbySession, EmbyItemCounts, EmbyLatestItem } from './client.js';
function mediaBadgeColor(type: string): string {
switch (type) {
case 'Movie':
return 'blue';
case 'Episode':
return 'purple';
case 'Audio':
return 'green';
case 'MusicVideo':
return 'teal';
default:
return 'gray';
}
}
export function toNowPlaying(sessions: readonly EmbySession[]): ListData {
const activeSessions = sessions.filter((s) => s.NowPlayingItem);
return {
items: activeSessions.map((session) => {
const item = session.NowPlayingItem!;
const title = item.SeriesName ? `${item.SeriesName}${item.Name}` : item.Name;
const playMethod = session.PlayState?.PlayMethod ?? 'Unknown';
return {
id: session.Id,
title: `${session.UserName}: ${title}`,
subtitle: playMethod === 'DirectPlay' ? 'Direct Play' : 'Transcoding',
badge: { text: item.Type, color: mediaBadgeColor(item.Type) }
};
})
};
}
export function toLibraryStats(counts: EmbyItemCounts): StatCardData {
const total = counts.MovieCount;
const parts: string[] = [];
if (counts.SeriesCount > 0) parts.push(`${counts.SeriesCount.toLocaleString()} series`);
if (counts.EpisodeCount > 0) parts.push(`${counts.EpisodeCount.toLocaleString()} episodes`);
if (counts.SongCount > 0) parts.push(`${counts.SongCount.toLocaleString()} songs`);
return {
label: 'Total Movies',
value: total.toLocaleString(),
subtitle: parts.join(' | ') || 'No additional media'
};
}
export function toRecentlyAdded(items: readonly EmbyLatestItem[]): ListData {
return {
items: items.map((item) => {
const title = item.SeriesName ? `${item.SeriesName}${item.Name}` : item.Name;
const dateAdded = new Date(item.DateCreated).toLocaleDateString();
return {
id: item.Id,
title,
subtitle: `Added ${dateAdded}`,
badge: { text: item.Type, color: mediaBadgeColor(item.Type) }
};
})
};
}
export function toActiveStreams(sessions: readonly EmbySession[]): StatCardData {
const active = sessions.filter((s) => s.NowPlayingItem);
const directCount = active.filter(
(s) => s.PlayState?.PlayMethod === 'DirectPlay'
).length;
const transcodeCount = active.length - directCount;
return {
label: 'Active Streams',
value: active.length,
subtitle:
active.length > 0
? `${directCount} direct | ${transcodeCount} transcoding`
: 'No active streams'
};
}
@@ -0,0 +1,69 @@
import { fetchWithTimeout } from '../base.js';
export interface ImmichServerStats {
readonly photos: number;
readonly videos: number;
readonly usage: number;
readonly usageByUser: readonly {
readonly userId: string;
readonly userName: string;
readonly photos: number;
readonly videos: number;
readonly usage: number;
}[];
}
export interface ImmichAsset {
readonly id: string;
readonly originalFileName: string;
readonly type: string;
readonly createdAt: string;
}
export interface ImmichVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
function buildUrl(appUrl: string, path: string): string {
return `${appUrl.replace(/\/$/, '')}/api/${path}`;
}
function authHeaders(apiKey: string): Record<string, string> {
return { 'x-api-key': apiKey };
}
export async function fetchServerStats(
appUrl: string,
apiKey: string
): Promise<ImmichServerStats> {
const url = buildUrl(appUrl, 'server/statistics');
const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) });
if (!res.ok) {
throw new Error(`Immich API returned ${res.status}`);
}
return res.json();
}
export async function fetchRecentAssets(
appUrl: string,
apiKey: string,
size = 10
): Promise<readonly ImmichAsset[]> {
const url = buildUrl(appUrl, `assets?order=desc&size=${size}`);
const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) });
if (!res.ok) {
throw new Error(`Immich API returned ${res.status}`);
}
return res.json();
}
export async function fetchVersion(appUrl: string, apiKey: string): Promise<ImmichVersion> {
const url = buildUrl(appUrl, 'server/version');
const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) });
if (!res.ok) {
throw new Error(`Immich API returned ${res.status}`);
}
return res.json();
}
@@ -0,0 +1,85 @@
import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js';
import { wrapError } from '../base.js';
import { immichAuthConfigSchema } from './schema.js';
import { fetchServerStats, fetchRecentAssets, fetchVersion } from './client.js';
import { toLibraryStats, toRecentUploads } from './transform.js';
const endpoints: readonly IntegrationEndpoint[] = [
{
id: 'library-stats',
name: 'Library Stats',
description: 'Total photos, videos, and storage usage',
renderer: 'stat-card',
refreshInterval: 300
},
{
id: 'recent-uploads',
name: 'Recent Uploads',
description: 'Latest uploaded assets',
renderer: 'list',
refreshInterval: 120
}
] as const;
async function testConnection(
appUrl: string,
config: Record<string, unknown>
): Promise<IntegrationTestResult> {
try {
const { apiKey } = immichAuthConfigSchema.parse(config);
const version = await fetchVersion(appUrl, apiKey);
return {
success: true,
message: `Connected to Immich v${version.major}.${version.minor}.${version.patch}`
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message: `Failed to connect to Immich: ${message}` };
}
}
async function fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData> {
const { apiKey } = immichAuthConfigSchema.parse(config);
try {
switch (endpointId) {
case 'library-stats': {
const stats = await fetchServerStats(appUrl, apiKey);
return {
endpointId,
renderer: 'stat-card',
data: toLibraryStats(stats),
fetchedAt: new Date().toISOString()
};
}
case 'recent-uploads': {
const assets = await fetchRecentAssets(appUrl, apiKey);
return {
endpointId,
renderer: 'list',
data: toRecentUploads(assets),
fetchedAt: new Date().toISOString()
};
}
default:
throw new Error(`Unknown endpoint: ${endpointId}`);
}
} catch (error) {
throw wrapError('immich', endpointId, error);
}
}
export const immichIntegration: Integration = {
id: 'immich',
name: 'Immich',
icon: 'immich',
description: 'Self-hosted photo and video management with library statistics',
authConfigSchema: immichAuthConfigSchema,
endpoints,
testConnection,
fetchData
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const immichAuthConfigSchema = z.object({
apiKey: z.string().min(1, 'API key is required')
});
export type ImmichAuthConfig = z.infer<typeof immichAuthConfigSchema>;
@@ -0,0 +1,51 @@
import type { StatCardData, ListData } from '../types.js';
import type { ImmichServerStats, ImmichAsset } from './client.js';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, exponent);
return `${value.toFixed(exponent > 0 ? 1 : 0)} ${units[exponent]}`;
}
function assetTypeBadgeColor(type: string): string {
switch (type.toUpperCase()) {
case 'IMAGE':
return 'blue';
case 'VIDEO':
return 'purple';
default:
return 'gray';
}
}
export function toLibraryStats(stats: ImmichServerStats): StatCardData {
const totalPhotos = stats.photos;
const totalVideos = stats.videos;
const storageUsed = formatBytes(stats.usage);
return {
label: 'Total Photos',
value: totalPhotos.toLocaleString(),
subtitle: `${totalVideos.toLocaleString()} videos | ${storageUsed} used`
};
}
export function toRecentUploads(assets: readonly ImmichAsset[]): ListData {
return {
items: assets.map((asset) => {
const dateUploaded = new Date(asset.createdAt).toLocaleDateString();
return {
id: asset.id,
title: asset.originalFileName,
subtitle: `Uploaded ${dateUploaded}`,
badge: {
text: asset.type,
color: assetTypeBadgeColor(asset.type)
}
};
})
};
}
@@ -0,0 +1,41 @@
import { fetchWithTimeout } from '../base.js';
export interface MetubeQueueItem {
readonly id: string;
readonly title: string;
readonly status: string;
readonly percent: number;
readonly msg?: string;
readonly filename?: string;
}
export interface MetubeQueueResponse {
readonly done: Record<string, MetubeQueueItem>;
readonly queue: Record<string, MetubeQueueItem>;
}
export interface MetubeHistoryResponse {
readonly done: Record<string, MetubeQueueItem>;
}
function buildUrl(appUrl: string, path: string): string {
return `${appUrl.replace(/\/$/, '')}/api/${path}`;
}
export async function fetchQueue(appUrl: string): Promise<MetubeQueueResponse> {
const url = buildUrl(appUrl, 'queue');
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`MeTube API returned ${res.status}`);
}
return res.json();
}
export async function fetchHistory(appUrl: string): Promise<MetubeHistoryResponse> {
const url = buildUrl(appUrl, 'history');
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`MeTube API returned ${res.status}`);
}
return res.json();
}
@@ -0,0 +1,66 @@
import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js';
import { wrapError } from '../base.js';
import { metubeAuthConfigSchema } from './schema.js';
import { fetchQueue } from './client.js';
import { toDownloadQueue } from './transform.js';
const endpoints: readonly IntegrationEndpoint[] = [
{
id: 'download-queue',
name: 'Download Queue',
description: 'Active and pending downloads with progress',
renderer: 'progress',
refreshInterval: 10
}
] as const;
async function testConnection(
appUrl: string,
_config: Record<string, unknown>
): Promise<IntegrationTestResult> {
try {
metubeAuthConfigSchema.parse(_config);
await fetchQueue(appUrl);
return { success: true, message: 'Connected to MeTube' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message: `Failed to connect to MeTube: ${message}` };
}
}
async function fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData> {
metubeAuthConfigSchema.parse(config);
try {
switch (endpointId) {
case 'download-queue': {
const response = await fetchQueue(appUrl);
return {
endpointId,
renderer: 'progress',
data: toDownloadQueue(response.queue),
fetchedAt: new Date().toISOString()
};
}
default:
throw new Error(`Unknown endpoint: ${endpointId}`);
}
} catch (error) {
throw wrapError('metube', endpointId, error);
}
}
export const metubeIntegration: Integration = {
id: 'metube',
name: 'MeTube',
icon: 'metube',
description: 'YouTube video downloader with queue progress',
authConfigSchema: metubeAuthConfigSchema,
endpoints,
testConnection,
fetchData
};
@@ -0,0 +1,5 @@
import { z } from 'zod';
export const metubeAuthConfigSchema = z.object({});
export type MetubeAuthConfig = z.infer<typeof metubeAuthConfigSchema>;
@@ -0,0 +1,17 @@
import type { ProgressData } from '../types.js';
import type { MetubeQueueItem } from './client.js';
export function toDownloadQueue(
queue: Record<string, MetubeQueueItem>
): ProgressData {
const entries = Object.entries(queue);
return {
items: entries.map(([id, item]) => ({
id,
label: item.title || item.filename || id,
progress: Math.round(item.percent ?? 0),
subtitle: item.status || undefined
}))
};
}
@@ -0,0 +1,113 @@
import { fetchWithTimeout } from '../base.js';
export interface PlankaCard {
readonly id: string;
readonly name: string;
readonly dueDate: string | null;
readonly boardId: string;
readonly listId: string;
readonly position: number;
}
export interface PlankaBoard {
readonly id: string;
readonly name: string;
}
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
const tokenCache = new Map<string, { readonly token: string; readonly expiresAt: number }>();
function buildBaseUrl(appUrl: string): string {
return `${appUrl.replace(/\/$/, '')}/api`;
}
export async function getToken(
appUrl: string,
email: string,
password: string
): Promise<string> {
const baseUrl = buildBaseUrl(appUrl);
const cached = tokenCache.get(baseUrl);
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
const res = await fetchWithTimeout(`${baseUrl}/access-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emailOrUsername: email, password })
});
if (!res.ok) {
tokenCache.delete(baseUrl);
throw new Error(`Planka login failed with status ${res.status}`);
}
const data = (await res.json()) as { item: string };
const entry = { token: data.item, expiresAt: Date.now() + TOKEN_TTL_MS };
tokenCache.set(baseUrl, entry);
return entry.token;
}
function clearToken(appUrl: string): void {
const baseUrl = buildBaseUrl(appUrl);
tokenCache.delete(baseUrl);
}
function authHeaders(token: string): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
};
}
async function authenticatedFetch(
appUrl: string,
path: string,
token: string,
email: string,
password: string
): Promise<Response> {
const baseUrl = buildBaseUrl(appUrl);
const url = `${baseUrl}${path}`;
const res = await fetchWithTimeout(url, { headers: authHeaders(token) });
if (res.status === 401) {
clearToken(appUrl);
const freshToken = await getToken(appUrl, email, password);
return fetchWithTimeout(url, { headers: authHeaders(freshToken) });
}
return res;
}
export async function fetchCards(
appUrl: string,
token: string,
email: string,
password: string
): Promise<PlankaCard[]> {
const res = await authenticatedFetch(appUrl, '/cards', token, email, password);
if (!res.ok) {
throw new Error(`Planka API returned ${res.status} fetching cards`);
}
const data = (await res.json()) as { items: PlankaCard[] };
return data.items;
}
export async function fetchBoards(
appUrl: string,
token: string,
email: string,
password: string
): Promise<PlankaBoard[]> {
const res = await authenticatedFetch(appUrl, '/boards', token, email, password);
if (!res.ok) {
throw new Error(`Planka API returned ${res.status} fetching boards`);
}
const data = (await res.json()) as { items: PlankaBoard[] };
return data.items;
}
+114
View File
@@ -0,0 +1,114 @@
import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js';
import { wrapError } from '../base.js';
import { plankaAuthConfigSchema } from './schema.js';
import { getToken, fetchCards, fetchBoards } from './client.js';
import { toMyCards, toOverdueCards, toBoardSummary } from './transform.js';
const endpoints: readonly IntegrationEndpoint[] = [
{
id: 'my-cards',
name: 'My Cards',
description: 'All cards across boards',
renderer: 'list',
refreshInterval: 120
},
{
id: 'overdue',
name: 'Overdue Cards',
description: 'Cards past due date',
renderer: 'list',
refreshInterval: 120
},
{
id: 'board-summary',
name: 'Board Summary',
description: 'Card counts per board',
renderer: 'stat-card',
refreshInterval: 300
}
] as const;
async function testConnection(
appUrl: string,
config: Record<string, unknown>
): Promise<IntegrationTestResult> {
try {
const { email, password } = plankaAuthConfigSchema.parse(config);
const token = await getToken(appUrl, email, password);
const boards = await fetchBoards(appUrl, token, email, password);
return {
success: true,
message: `Connected — ${boards.length} board${boards.length === 1 ? '' : 's'} found`
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message: `Failed to connect to Planka: ${message}` };
}
}
async function fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData> {
const { email, password } = plankaAuthConfigSchema.parse(config);
try {
const token = await getToken(appUrl, email, password);
switch (endpointId) {
case 'my-cards': {
const [cards, boards] = await Promise.all([
fetchCards(appUrl, token, email, password),
fetchBoards(appUrl, token, email, password)
]);
return {
endpointId,
renderer: 'list',
data: toMyCards(cards, boards),
fetchedAt: new Date().toISOString()
};
}
case 'overdue': {
const [cards, boards] = await Promise.all([
fetchCards(appUrl, token, email, password),
fetchBoards(appUrl, token, email, password)
]);
return {
endpointId,
renderer: 'list',
data: toOverdueCards(cards, boards),
fetchedAt: new Date().toISOString()
};
}
case 'board-summary': {
const [cards, boards] = await Promise.all([
fetchCards(appUrl, token, email, password),
fetchBoards(appUrl, token, email, password)
]);
return {
endpointId,
renderer: 'stat-card',
data: toBoardSummary(cards, boards),
fetchedAt: new Date().toISOString()
};
}
default:
throw new Error(`Unknown endpoint: ${endpointId}`);
}
} catch (error) {
throw wrapError('planka', endpointId, error);
}
}
export const plankaIntegration: Integration = {
id: 'planka',
name: 'Planka',
icon: 'planka',
description: 'Project board cards, overdue tracking, and board summaries',
authConfigSchema: plankaAuthConfigSchema,
endpoints,
testConnection,
fetchData
};
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const plankaAuthConfigSchema = z.object({
email: z.string().email('Valid email required'),
password: z.string().min(1, 'Password is required')
});
export type PlankaAuthConfig = z.infer<typeof plankaAuthConfigSchema>;
@@ -0,0 +1,74 @@
import type { StatCardData, ListData } from '../types.js';
import type { PlankaCard, PlankaBoard } from './client.js';
function boardNameById(
boards: readonly PlankaBoard[],
boardId: string
): string {
const board = boards.find((b) => b.id === boardId);
return board?.name ?? 'Unknown board';
}
function isOverdue(dueDate: string | null): boolean {
if (!dueDate) return false;
return new Date(dueDate) < new Date();
}
function daysOverdue(dueDate: string): number {
return Math.ceil((Date.now() - new Date(dueDate).getTime()) / (1000 * 60 * 60 * 24));
}
export function toMyCards(
cards: readonly PlankaCard[],
boards: readonly PlankaBoard[]
): ListData {
return {
items: cards.map((c) => ({
id: c.id,
title: c.name,
subtitle: boardNameById(boards, c.boardId),
...(isOverdue(c.dueDate)
? { badge: { text: 'Overdue', color: 'red' } }
: {})
}))
};
}
export function toOverdueCards(
cards: readonly PlankaCard[],
boards: readonly PlankaBoard[]
): ListData {
const overdue = cards.filter((c) => isOverdue(c.dueDate));
return {
items: overdue.map((c) => ({
id: c.id,
title: c.name,
subtitle: boardNameById(boards, c.boardId),
badge: {
text: `${daysOverdue(c.dueDate!)} days overdue`,
color: 'red'
}
}))
};
}
export function toBoardSummary(
cards: readonly PlankaCard[],
boards: readonly PlankaBoard[]
): StatCardData {
const countsByBoard = new Map<string, number>();
for (const card of cards) {
countsByBoard.set(card.boardId, (countsByBoard.get(card.boardId) ?? 0) + 1);
}
const parts = boards
.filter((b) => countsByBoard.has(b.id))
.map((b) => `${b.name}: ${countsByBoard.get(b.id)}`);
return {
label: 'Total Cards',
value: cards.length,
subtitle: parts.join(', ')
};
}
+10
View File
@@ -6,6 +6,11 @@ import { portainerIntegration } from './portainer/index.js';
import { giteaIntegration } from './gitea/index.js'; import { giteaIntegration } from './gitea/index.js';
import { npmIntegration } from './npm/index.js'; import { npmIntegration } from './npm/index.js';
import { authentikIntegration } from './authentik/index.js'; import { authentikIntegration } from './authentik/index.js';
import { embyIntegration } from './emby/index.js';
import { immichIntegration } from './immich/index.js';
import { delugeIntegration } from './deluge/index.js';
import { metubeIntegration } from './metube/index.js';
import { plankaIntegration } from './planka/index.js';
const integrations = new Map<string, Integration>(); const integrations = new Map<string, Integration>();
@@ -74,3 +79,8 @@ register(portainerIntegration);
register(giteaIntegration); register(giteaIntegration);
register(npmIntegration); register(npmIntegration);
register(authentikIntegration); register(authentikIntegration);
register(embyIntegration);
register(immichIntegration);
register(delugeIntegration);
register(metubeIntegration);
register(plankaIntegration);