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:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(', ')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user