/** * DC-104: App Catalog API — curated templates with categories and search * * Exposes the existing app-templates.js as a browsable catalog. * GET /api/v1/catalog — list all apps (with optional category filter) * GET /api/v1/catalog/:appId — get details for a specific app * GET /api/v1/catalog/search — search apps by name/category/keyword */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); // Category mapping for common apps const CATEGORY_MAP = { plex: 'media', jellyfin: 'media', emby: 'media', sonarr: 'media', radarr: 'media', prowlarr: 'media', lidarr: 'media', readarr: 'media', qbittorrent: 'media', transmission: 'media', sabnzbd: 'media', nzbget: 'media', nextcloud: 'productivity', vaultwarden: 'productivity', gitea: 'development', portainer: 'development', code: 'development', node: 'development', redis: 'database', postgres: 'database', mariadb: 'database', mongo: 'database', mysql: 'database', nginx: 'network', caddy: 'network', adguard: 'network', pihole: 'network', technitium: 'network', wireguard: 'network', homeassistant: 'smart-home', mosquitto: 'smart-home', grafana: 'monitoring', prometheus: 'monitoring', uptimekuma: 'monitoring', }; function getTemplateCategory(template) { const id = (template.id || template.name || '').toLowerCase(); for (const [key, cat] of Object.entries(CATEGORY_MAP)) { if (id.includes(key)) return cat; } return 'other'; } module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) { const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)); const router = express.Router(); // GET /api/v1/catalog — list all apps router.get('/catalog', wrap(async (req, res) => { const { category, sort } = req.query; let apps = APP_TEMPLATES || []; // APP_TEMPLATES can be an array or an object map { plex: {...}, ... } let appArray = Array.isArray(apps) ? apps : Object.values(apps); // Build catalog entries let entries = appArray.map(t => ({ id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'), name: t.name, description: t.description || '', category: getTemplateCategory(t), logo: t.logo || null, popular: ['plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent'] .includes((t.id || t.name || '').toLowerCase().replace(/\s+/g, '-')), })); // Filter by category if (category && category !== 'all') { entries = entries.filter(e => e.category === category); } // Sort if (sort === 'name') { entries.sort((a, b) => a.name.localeCompare(b.name)); } else { // Default: popular first, then alphabetical entries.sort((a, b) => { if (a.popular !== b.popular) return a.popular ? -1 : 1; return a.name.localeCompare(b.name); }); } // Get categories const categories = [...new Set(entries.map(e => e.category))].sort(); ok(res, { total: entries.length, categories, apps: entries, }); })); // GET /api/v1/catalog/search?q=plex router.get('/catalog/search', wrap(async (req, res) => { const q = (req.query.q || '').toLowerCase().trim(); if (!q) { return errorResponse(res, 400, 'Search query (q) is required'); } const allApps = APP_TEMPLATES || []; const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps); const apps = appArray.filter(t => { const name = (t.name || '').toLowerCase(); const desc = (t.description || '').toLowerCase(); const cat = getTemplateCategory(t).toLowerCase(); return name.includes(q) || desc.includes(q) || cat.includes(q); }).map(t => ({ id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'), name: t.name, description: t.description || '', category: getTemplateCategory(t), })); ok(res, { query: q, results: apps.length, apps }); })); // GET /api/v1/catalog/:appId — get specific app details router.get('/catalog/:appId', wrap(async (req, res) => { const appId = req.params.appId; const allApps = APP_TEMPLATES || []; const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps); const app = appArray.find(t => { const tid = (t.id || t.name?.toLowerCase().replace(/\s+/g, '-')); return tid === appId; }); if (!app) { return errorResponse(res, 404, `App '${appId}' not found in catalog`); } ok(res, { id: app.id || appId, name: app.name, description: app.description || '', category: getTemplateCategory(app), image: app.image || '', ports: app.ports || [], env: app.env || {}, volumes: app.volumes || [], network: app.network || 'bridge', restart: app.restart || 'unless-stopped', }); })); return router; };