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 { 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<string, Integration>();
|
||||
|
||||
@@ -74,3 +79,8 @@ register(portainerIntegration);
|
||||
register(giteaIntegration);
|
||||
register(npmIntegration);
|
||||
register(authentikIntegration);
|
||||
register(embyIntegration);
|
||||
register(immichIntegration);
|
||||
register(delugeIntegration);
|
||||
register(metubeIntegration);
|
||||
register(plankaIntegration);
|
||||
|
||||
Reference in New Issue
Block a user