APP_TEMPLATES is exported as { plex: {...}, jellyfin: {...}, ... } not
an array. All three catalog endpoints now handle both formats.
139 lines
4.8 KiB
JavaScript
139 lines
4.8 KiB
JavaScript
/**
|
|
* 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;
|
|
};
|