diff --git a/src/lib/server/integrations/deluge/client.ts b/src/lib/server/integrations/deluge/client.ts new file mode 100644 index 0000000..cc321b4 --- /dev/null +++ b/src/lib/server/integrations/deluge/client.ts @@ -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; + 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 { + const headers: Record = { '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 { + 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 { + 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 { + const url = rpcUrl(appUrl); + const res = await rpcCall(url, 'core.get_free_space', [path], 3, cookie); + return res.json(); +} diff --git a/src/lib/server/integrations/deluge/index.ts b/src/lib/server/integrations/deluge/index.ts new file mode 100644 index 0000000..4994131 --- /dev/null +++ b/src/lib/server/integrations/deluge/index.ts @@ -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 +): Promise { + 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, + endpointId: string +): Promise { + 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 +}; diff --git a/src/lib/server/integrations/deluge/schema.ts b/src/lib/server/integrations/deluge/schema.ts new file mode 100644 index 0000000..320f939 --- /dev/null +++ b/src/lib/server/integrations/deluge/schema.ts @@ -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; diff --git a/src/lib/server/integrations/deluge/transform.ts b/src/lib/server/integrations/deluge/transform.ts new file mode 100644 index 0000000..c5be33b --- /dev/null +++ b/src/lib/server/integrations/deluge/transform.ts @@ -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 +): 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 } + }; +} diff --git a/src/lib/server/integrations/emby/client.ts b/src/lib/server/integrations/emby/client.ts new file mode 100644 index 0000000..422b2b9 --- /dev/null +++ b/src/lib/server/integrations/emby/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/lib/server/integrations/emby/index.ts b/src/lib/server/integrations/emby/index.ts new file mode 100644 index 0000000..c29178d --- /dev/null +++ b/src/lib/server/integrations/emby/index.ts @@ -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 +): Promise { + 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, + endpointId: string +): Promise { + 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 +}; diff --git a/src/lib/server/integrations/emby/schema.ts b/src/lib/server/integrations/emby/schema.ts new file mode 100644 index 0000000..e3bbe8c --- /dev/null +++ b/src/lib/server/integrations/emby/schema.ts @@ -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; diff --git a/src/lib/server/integrations/emby/transform.ts b/src/lib/server/integrations/emby/transform.ts new file mode 100644 index 0000000..7fb7a91 --- /dev/null +++ b/src/lib/server/integrations/emby/transform.ts @@ -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' + }; +} diff --git a/src/lib/server/integrations/immich/client.ts b/src/lib/server/integrations/immich/client.ts new file mode 100644 index 0000000..157af8e --- /dev/null +++ b/src/lib/server/integrations/immich/client.ts @@ -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 { + return { 'x-api-key': apiKey }; +} + +export async function fetchServerStats( + appUrl: string, + apiKey: string +): Promise { + 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 { + 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 { + 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(); +} diff --git a/src/lib/server/integrations/immich/index.ts b/src/lib/server/integrations/immich/index.ts new file mode 100644 index 0000000..3ef0cc8 --- /dev/null +++ b/src/lib/server/integrations/immich/index.ts @@ -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 +): Promise { + 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, + endpointId: string +): Promise { + 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 +}; diff --git a/src/lib/server/integrations/immich/schema.ts b/src/lib/server/integrations/immich/schema.ts new file mode 100644 index 0000000..637a0ec --- /dev/null +++ b/src/lib/server/integrations/immich/schema.ts @@ -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; diff --git a/src/lib/server/integrations/immich/transform.ts b/src/lib/server/integrations/immich/transform.ts new file mode 100644 index 0000000..38f3d63 --- /dev/null +++ b/src/lib/server/integrations/immich/transform.ts @@ -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) + } + }; + }) + }; +} diff --git a/src/lib/server/integrations/metube/client.ts b/src/lib/server/integrations/metube/client.ts new file mode 100644 index 0000000..cfe8564 --- /dev/null +++ b/src/lib/server/integrations/metube/client.ts @@ -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; + readonly queue: Record; +} + +export interface MetubeHistoryResponse { + readonly done: Record; +} + +function buildUrl(appUrl: string, path: string): string { + return `${appUrl.replace(/\/$/, '')}/api/${path}`; +} + +export async function fetchQueue(appUrl: string): Promise { + 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 { + const url = buildUrl(appUrl, 'history'); + const res = await fetchWithTimeout(url); + if (!res.ok) { + throw new Error(`MeTube API returned ${res.status}`); + } + return res.json(); +} diff --git a/src/lib/server/integrations/metube/index.ts b/src/lib/server/integrations/metube/index.ts new file mode 100644 index 0000000..47a0b39 --- /dev/null +++ b/src/lib/server/integrations/metube/index.ts @@ -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 +): Promise { + 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, + endpointId: string +): Promise { + 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 +}; diff --git a/src/lib/server/integrations/metube/schema.ts b/src/lib/server/integrations/metube/schema.ts new file mode 100644 index 0000000..f311ff3 --- /dev/null +++ b/src/lib/server/integrations/metube/schema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const metubeAuthConfigSchema = z.object({}); + +export type MetubeAuthConfig = z.infer; diff --git a/src/lib/server/integrations/metube/transform.ts b/src/lib/server/integrations/metube/transform.ts new file mode 100644 index 0000000..5cf19b1 --- /dev/null +++ b/src/lib/server/integrations/metube/transform.ts @@ -0,0 +1,17 @@ +import type { ProgressData } from '../types.js'; +import type { MetubeQueueItem } from './client.js'; + +export function toDownloadQueue( + queue: Record +): 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 + })) + }; +} diff --git a/src/lib/server/integrations/planka/client.ts b/src/lib/server/integrations/planka/client.ts new file mode 100644 index 0000000..9e80e81 --- /dev/null +++ b/src/lib/server/integrations/planka/client.ts @@ -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(); + +function buildBaseUrl(appUrl: string): string { + return `${appUrl.replace(/\/$/, '')}/api`; +} + +export async function getToken( + appUrl: string, + email: string, + password: string +): Promise { + 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 { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; +} + +async function authenticatedFetch( + appUrl: string, + path: string, + token: string, + email: string, + password: string +): Promise { + 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 { + 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 { + 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; +} diff --git a/src/lib/server/integrations/planka/index.ts b/src/lib/server/integrations/planka/index.ts new file mode 100644 index 0000000..0998f25 --- /dev/null +++ b/src/lib/server/integrations/planka/index.ts @@ -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 +): Promise { + 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, + endpointId: string +): Promise { + 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 +}; diff --git a/src/lib/server/integrations/planka/schema.ts b/src/lib/server/integrations/planka/schema.ts new file mode 100644 index 0000000..89ce04a --- /dev/null +++ b/src/lib/server/integrations/planka/schema.ts @@ -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; diff --git a/src/lib/server/integrations/planka/transform.ts b/src/lib/server/integrations/planka/transform.ts new file mode 100644 index 0000000..a97237c --- /dev/null +++ b/src/lib/server/integrations/planka/transform.ts @@ -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(); + 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(', ') + }; +} diff --git a/src/lib/server/integrations/registry.ts b/src/lib/server/integrations/registry.ts index b2bdb5b..6b73016 100644 --- a/src/lib/server/integrations/registry.ts +++ b/src/lib/server/integrations/registry.ts @@ -6,6 +6,11 @@ import { portainerIntegration } from './portainer/index.js'; import { giteaIntegration } from './gitea/index.js'; import { npmIntegration } from './npm/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(); @@ -74,3 +79,8 @@ register(portainerIntegration); register(giteaIntegration); register(npmIntegration); register(authentikIntegration); +register(embyIntegration); +register(immichIntegration); +register(delugeIntegration); +register(metubeIntegration); +register(plankaIntegration);