feat: Add image sources management and update server middleware
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled

This commit is contained in:
2026-02-17 20:44:53 -05:00
parent 854fd199bf
commit 706d48f549
5 changed files with 789 additions and 21 deletions

View File

@@ -120,7 +120,7 @@
color: var(--muted); color: var(--muted);
word-break: break-all; word-break: break-all;
font-family: monospace; font-family: monospace;
font-size: 0.8em; font-size: 0.7em;
} }
.tags-editor { .tags-editor {
@@ -244,6 +244,25 @@
word-break: break-word; word-break: break-word;
} }
.timeline-time {
display: block;
}
.timeline-source {
display: inline-block;
margin-top: 6px;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.85em;
color: var(--text);
background: rgba(255, 255, 255, 0.04);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timeline-item:hover { .timeline-item:hover {
background: #283246; background: #283246;
} }
@@ -257,7 +276,7 @@
.controls { .controls {
background: var(--panel); background: var(--panel);
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 10px;
margin-bottom: 12px; margin-bottom: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
@@ -319,7 +338,7 @@
.stats { .stats {
background: var(--panel); background: var(--panel);
border-radius: 4px; border-radius: 4px;
padding: 10px 16px; padding: 9px 16px;
display: flex; display: flex;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
@@ -340,7 +359,7 @@
} }
.stat-value { .stat-value {
font-size: 1.4em; font-size: 1.1em;
font-weight: bold; font-weight: bold;
color: var(--accent); color: var(--accent);
} }
@@ -433,6 +452,12 @@
<option value="">All Tags</option> <option value="">All Tags</option>
</select> </select>
</label> </label>
<label>
Filter by source:
<select id="sourceSelect">
<option value="">All Sources</option>
</select>
</label>
<div class="delete-group"> <div class="delete-group">
<label> <label>
Delete before: Delete before:
@@ -472,6 +497,10 @@
<span class="info-label">Time:</span> <span class="info-label">Time:</span>
<span class="info-value" id="previewTime">--</span> <span class="info-value" id="previewTime">--</span>
</div> </div>
<div class="info-row">
<span class="info-label">Source:</span>
<span class="info-value" id="previewSource">--</span>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Size:</span> <span class="info-label">Size:</span>
<span class="info-value" id="previewSize">--</span> <span class="info-value" id="previewSize">--</span>
@@ -516,9 +545,29 @@
const mainContent = document.getElementById('mainContent'); const mainContent = document.getElementById('mainContent');
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
const tagSelect = document.getElementById('tagSelect'); const tagSelect = document.getElementById('tagSelect');
const sourceSelect = document.getElementById('sourceSelect');
const deleteBeforeDateInput = document.getElementById('deleteBeforeDate'); const deleteBeforeDateInput = document.getElementById('deleteBeforeDate');
const deleteBeforeBtn = document.getElementById('deleteBeforeBtn'); const deleteBeforeBtn = document.getElementById('deleteBeforeBtn');
const deleteStatus = document.getElementById('deleteStatus'); const deleteStatus = document.getElementById('deleteStatus');
const previewSource = document.getElementById('previewSource');
let sourceNameByUrl = new Map();
let activeTimelineItem = null;
function normalizeSourceUrl(url) {
if (!url) return '';
return String(url).trim().replace(/\/+$/, '');
}
function getSourceMeta(image) {
const sourceUrl = image?.source_url || '';
const normalized = normalizeSourceUrl(sourceUrl);
const configuredName = sourceNameByUrl.get(normalized);
return {
name: configuredName || sourceUrl || 'Unknown source',
url: sourceUrl
};
}
function parseSqliteDate(dateString) { function parseSqliteDate(dateString) {
if (!dateString) return new Date(NaN); if (!dateString) return new Date(NaN);
@@ -576,6 +625,23 @@
throw new Error(`Failed to fetch images: ${err.message}`); throw new Error(`Failed to fetch images: ${err.message}`);
} }
// Fetch source config (non-critical)
try {
const sourcesResponse = await fetch(`${API_BASE}/api/sources`);
if (sourcesResponse.ok) {
const sourcesData = await sourcesResponse.json();
const configuredSources = sourcesData?.config?.sources || [];
sourceNameByUrl = new Map(
configuredSources
.filter(source => source && source.url)
.map(source => [normalizeSourceUrl(source.url), source.name || source.url])
);
}
} catch (err) {
console.warn('Warning: Could not fetch source config:', err);
sourceNameByUrl = new Map();
}
// Fetch tags (non-critical) // Fetch tags (non-critical)
try { try {
const tagsResponse = await fetch(`${API_BASE}/api/tags`); const tagsResponse = await fetch(`${API_BASE}/api/tags`);
@@ -603,6 +669,7 @@
// Update tag filter // Update tag filter
updateTagFilter(); updateTagFilter();
updateSourceFilter();
// Render timeline // Render timeline
renderTimeline(); renderTimeline();
@@ -619,33 +686,75 @@
function updateTagFilter() { function updateTagFilter() {
const currentValue = tagSelect.value; const currentValue = tagSelect.value;
tagSelect.innerHTML = '<option value="">All Tags</option>'; tagSelect.innerHTML = '<option value="">All Tags</option>';
allTags.forEach(tag => {
const mergedTags = new Set(allTags);
allImages.forEach((image) => {
(image.tags || []).forEach((tag) => mergedTags.add(tag));
});
Array.from(mergedTags).sort().forEach(tag => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = tag; option.value = tag;
option.textContent = tag; option.textContent = tag;
tagSelect.appendChild(option); tagSelect.appendChild(option);
}); });
tagSelect.value = currentValue;
tagSelect.value = Array.from(tagSelect.options).some(option => option.value === currentValue)
? currentValue
: '';
} }
function renderTimeline() { function updateSourceFilter() {
// Filter images const currentValue = sourceSelect.value;
const selectedTag = tagSelect.value; sourceSelect.innerHTML = '<option value="">All Sources</option>';
let filtered = allImages;
if (selectedTag) {
filtered = allImages.filter(img =>
img.tags && img.tags.includes(selectedTag)
);
}
// Sort images const sourceOptions = new Map();
const sorted = [...filtered]; allImages.forEach((image) => {
const normalized = normalizeSourceUrl(image.source_url);
if (!normalized) return;
sourceOptions.set(normalized, getSourceMeta(image).name);
});
Array.from(sourceOptions.entries())
.sort((a, b) => a[1].localeCompare(b[1]))
.forEach(([sourceKey, sourceLabel]) => {
const option = document.createElement('option');
option.value = sourceKey;
option.textContent = sourceLabel;
sourceSelect.appendChild(option);
});
sourceSelect.value = Array.from(sourceSelect.options).some(option => option.value === currentValue)
? currentValue
: '';
}
function getFilteredImages() {
const selectedTag = tagSelect.value;
const selectedSource = sourceSelect.value;
return allImages.filter((image) => {
const tagMatch = !selectedTag || (image.tags && image.tags.includes(selectedTag));
const sourceMatch = !selectedSource || normalizeSourceUrl(image.source_url) === selectedSource;
return tagMatch && sourceMatch;
});
}
function getSortedImages(images) {
const sorted = [...images];
if (sortSelect.value === 'oldest') { if (sortSelect.value === 'oldest') {
sorted.reverse(); sorted.reverse();
} }
return sorted;
}
function renderTimeline() {
const filtered = getFilteredImages();
const sorted = getSortedImages(filtered);
// Clear timeline // Clear timeline
timeline.innerHTML = ''; timeline.innerHTML = '';
activeTimelineItem = null;
if (sorted.length === 0) { if (sorted.length === 0) {
empty.style.display = 'block'; empty.style.display = 'block';
@@ -654,10 +763,21 @@
} }
// Add items // Add items
const fragment = document.createDocumentFragment();
sorted.forEach((image, index) => { sorted.forEach((image, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'timeline-item'; item.className = 'timeline-item';
item.textContent = formatCompactDateTime(image.fetched_at);
const timeText = document.createElement('span');
timeText.className = 'timeline-time';
timeText.textContent = formatCompactDateTime(image.fetched_at);
const sourceText = document.createElement('span');
sourceText.className = 'timeline-source';
sourceText.textContent = getSourceMeta(image).name;
item.appendChild(timeText);
item.appendChild(sourceText);
item.addEventListener('mouseenter', () => showPreview(image, item)); item.addEventListener('mouseenter', () => showPreview(image, item));
@@ -666,9 +786,11 @@
item.classList.add('active'); item.classList.add('active');
} }
timeline.appendChild(item); fragment.appendChild(item);
}); });
timeline.appendChild(fragment);
mainContent.style.display = 'flex'; mainContent.style.display = 'flex';
empty.style.display = 'none'; empty.style.display = 'none';
} }
@@ -677,8 +799,11 @@
currentImage = image; currentImage = image;
// Update active state // Update active state
document.querySelectorAll('.timeline-item').forEach(i => i.classList.remove('active')); if (activeTimelineItem) {
item.classList.add('active'); activeTimelineItem.classList.remove('active');
}
activeTimelineItem = item;
activeTimelineItem.classList.add('active');
// Update preview // Update preview
const previewImage = document.getElementById('previewImage'); const previewImage = document.getElementById('previewImage');
@@ -687,7 +812,10 @@
document.querySelector('.preview-placeholder').style.display = 'none'; document.querySelector('.preview-placeholder').style.display = 'none';
// Update info // Update info
const sourceMeta = getSourceMeta(image);
document.getElementById('previewTime').textContent = formatDate(image.fetched_at); document.getElementById('previewTime').textContent = formatDate(image.fetched_at);
previewSource.textContent = sourceMeta.name;
previewSource.title = sourceMeta.url || sourceMeta.name;
document.getElementById('previewSize').textContent = formatSize(image.filesize); document.getElementById('previewSize').textContent = formatSize(image.filesize);
document.getElementById('previewHash').textContent = image.file_hash.substring(0, 32) + '...'; document.getElementById('previewHash').textContent = image.file_hash.substring(0, 32) + '...';
@@ -710,6 +838,7 @@
} }
} }
function normalizeBeforeDateInput(value) { function normalizeBeforeDateInput(value) {
if (!value) return ''; if (!value) return '';
const normalized = value.replace('T', ' '); const normalized = value.replace('T', ' ');
@@ -819,6 +948,7 @@
sortSelect.addEventListener('change', renderTimeline); sortSelect.addEventListener('change', renderTimeline);
tagSelect.addEventListener('change', renderTimeline); tagSelect.addEventListener('change', renderTimeline);
sourceSelect.addEventListener('change', renderTimeline);
// Initial load // Initial load
loadImages(); loadImages();

View File

@@ -45,6 +45,11 @@
<h3>Image Gallery</h3> <h3>Image Gallery</h3>
<p>Browse captured images on an interactive timeline</p> <p>Browse captured images on an interactive timeline</p>
</a> </a>
<a href="/sources.html" class="feature-card">
<div class="feature-icon">📡</div>
<h3>Image Sources</h3>
<p>Add cameras, edit tags, and manage fetch intervals</p>
</a>
<a href="/api/stats" class="feature-card"> <a href="/api/stats" class="feature-card">
<div class="feature-icon">📊</div> <div class="feature-icon">📊</div>
<h3>Statistics</h3> <h3>Statistics</h3>

465
public/sources.html Normal file
View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeBase Sources</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
color-scheme: dark;
--bg: #0f131a;
--bg-accent: #161b24;
--panel: #1b2230;
--panel-strong: #222b3b;
--text: #e6e9ef;
--muted: #aab3c2;
--accent: #7aa2f7;
--accent-strong: #5d87f0;
--danger: #e05b5b;
--danger-strong: #c94b4b;
--border: #2b3446;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: radial-gradient(circle at top left, #1c2433, var(--bg));
min-height: 100vh;
padding: 10px;
color: var(--text);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.page-title {
font-size: 1.8em;
font-weight: 600;
}
.nav-links {
display: flex;
gap: 10px;
}
.nav-links a {
color: var(--muted);
text-decoration: none;
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 3px;
}
.nav-links a:hover {
color: var(--text);
border-color: var(--accent);
}
.sources-panel {
background: var(--panel);
border-radius: 4px;
padding: 12px 16px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.sources-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 12px;
}
.sources-title {
font-weight: 600;
color: var(--text);
}
.sources-interval {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9em;
}
.sources-interval input {
width: 110px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
color: var(--text);
}
.sources-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.sources-form input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
color: var(--text);
font-size: 0.9em;
}
.sources-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sources-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.source-item {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.8fr 0.6fr auto auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
}
.source-edit-input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: #1d2636;
color: var(--text);
font-size: 0.85em;
}
.source-toggle {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 0.85em;
}
.tag-add-btn {
padding: 6px 12px;
background: var(--accent);
color: #0f131a;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: 600;
font-size: 0.85em;
}
.tag-add-btn:hover {
background: var(--accent-strong);
}
.source-save {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 0.8em;
}
.source-save:hover {
background: var(--accent);
color: #0f131a;
}
.source-delete {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 0.8em;
}
.source-delete:hover {
background: var(--danger);
color: white;
}
.sources-status {
margin-top: 10px;
font-size: 0.85em;
color: var(--muted);
}
@media (max-width: 900px) {
.source-item {
grid-template-columns: 1fr;
}
.sources-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<div class="page-title">Image Sources</div>
<div class="nav-links">
<a href="/index.html">Home</a>
<a href="/gallery.html">Gallery</a>
</div>
</div>
<div class="sources-panel">
<div class="sources-header">
<div class="sources-title">Manage Sources</div>
<div class="sources-interval">
Fetch interval (minutes)
<input type="number" id="fetchIntervalInput" min="0.01" step="0.01">
<button class="tag-add-btn" id="saveIntervalBtn">Save</button>
</div>
</div>
<div class="sources-form">
<input type="text" id="sourceNameInput" placeholder="Name">
<input type="url" id="sourceUrlInput" placeholder="Image URL">
<input type="text" id="sourceTagsInput" placeholder="Tags (comma separated)">
<div class="sources-actions">
<label class="source-toggle">
<input type="checkbox" id="sourceEnabledInput" checked>
Enabled
</label>
<button class="tag-add-btn" id="addSourceBtn">Add Source</button>
</div>
</div>
<div class="sources-list" id="sourcesList"></div>
<div class="sources-status" id="sourcesStatus"></div>
</div>
</div>
<script>
const API_BASE = '';
const fetchIntervalInput = document.getElementById('fetchIntervalInput');
const saveIntervalBtn = document.getElementById('saveIntervalBtn');
const sourceNameInput = document.getElementById('sourceNameInput');
const sourceUrlInput = document.getElementById('sourceUrlInput');
const sourceTagsInput = document.getElementById('sourceTagsInput');
const sourceEnabledInput = document.getElementById('sourceEnabledInput');
const addSourceBtn = document.getElementById('addSourceBtn');
const sourcesList = document.getElementById('sourcesList');
const sourcesStatus = document.getElementById('sourcesStatus');
async function loadSources() {
try {
const response = await fetch(`${API_BASE}/api/sources`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const config = data.config || {};
fetchIntervalInput.value = config.fetchInterval ?? '';
renderSources(config.sources || []);
sourcesStatus.textContent = '';
} catch (err) {
console.error('Error loading sources:', err);
sourcesStatus.textContent = 'Failed to load sources.';
}
}
function renderSources(sources) {
sourcesList.innerHTML = '';
if (!sources.length) {
sourcesList.innerHTML = '<div class="sources-status">No sources configured yet.</div>';
return;
}
sources.forEach((source, index) => {
const item = document.createElement('div');
item.className = 'source-item';
const nameInput = document.createElement('input');
nameInput.className = 'source-edit-input';
nameInput.type = 'text';
nameInput.value = source.name || '';
const urlInput = document.createElement('input');
urlInput.className = 'source-edit-input';
urlInput.type = 'url';
urlInput.value = source.url || '';
const tagsInput = document.createElement('input');
tagsInput.className = 'source-edit-input';
tagsInput.type = 'text';
tagsInput.value = (source.tags || []).join(', ');
const toggleWrap = document.createElement('label');
toggleWrap.className = 'source-toggle';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = Boolean(source.enabled);
toggle.addEventListener('change', () => updateSource(index, { enabled: toggle.checked }));
toggleWrap.appendChild(toggle);
toggleWrap.appendChild(document.createTextNode('Enabled'));
const saveBtn = document.createElement('button');
saveBtn.className = 'source-save';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => {
updateSource(index, {
name: nameInput.value.trim(),
url: urlInput.value.trim(),
tags: parseTagsInput(tagsInput.value)
});
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'source-delete';
deleteBtn.textContent = 'Remove';
deleteBtn.addEventListener('click', () => removeSource(index));
item.appendChild(nameInput);
item.appendChild(urlInput);
item.appendChild(tagsInput);
item.appendChild(toggleWrap);
item.appendChild(saveBtn);
item.appendChild(deleteBtn);
sourcesList.appendChild(item);
});
}
function parseTagsInput(value) {
return value
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
}
async function addSource() {
const name = sourceNameInput.value.trim();
const url = sourceUrlInput.value.trim();
const tags = parseTagsInput(sourceTagsInput.value);
const enabled = sourceEnabledInput.checked;
if (!name || !url) {
sourcesStatus.textContent = 'Name and URL are required.';
return;
}
sourcesStatus.textContent = 'Saving source...';
try {
const response = await fetch(`${API_BASE}/api/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, tags, enabled })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourceNameInput.value = '';
sourceUrlInput.value = '';
sourceTagsInput.value = '';
sourceEnabledInput.checked = true;
sourcesStatus.textContent = 'Source added.';
await loadSources();
} catch (err) {
console.error('Error adding source:', err);
sourcesStatus.textContent = 'Failed to add source.';
}
}
async function updateSource(index, updates) {
try {
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Sources updated.';
await loadSources();
} catch (err) {
console.error('Error updating source:', err);
sourcesStatus.textContent = 'Failed to update source.';
}
}
async function removeSource(index) {
const confirmed = window.confirm('Remove this source?');
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Source removed.';
await loadSources();
} catch (err) {
console.error('Error removing source:', err);
sourcesStatus.textContent = 'Failed to remove source.';
}
}
async function saveFetchInterval() {
const intervalValue = Number(fetchIntervalInput.value);
if (!Number.isFinite(intervalValue) || intervalValue <= 0) {
sourcesStatus.textContent = 'Fetch interval must be a positive number.';
return;
}
sourcesStatus.textContent = 'Saving interval...';
try {
const response = await fetch(`${API_BASE}/api/sources/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ fetchInterval: intervalValue.toString() })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Interval updated.';
await loadSources();
} catch (err) {
console.error('Error saving interval:', err);
sourcesStatus.textContent = 'Failed to update interval.';
}
}
addSourceBtn.addEventListener('click', addSource);
saveIntervalBtn.addEventListener('click', saveFetchInterval);
document.addEventListener('DOMContentLoaded', loadSources);
</script>
</body>
</html>

View File

@@ -1,9 +1,58 @@
const express = require('express'); const express = require('express');
const path = require('path');
const fs = require('fs');
const router = express.Router(); const router = express.Router();
const database = require('../lib/database'); const database = require('../lib/database');
const storage = require('../lib/storage'); const storage = require('../lib/storage');
const imageFetcher = require('../lib/image-fetcher'); const imageFetcher = require('../lib/image-fetcher');
const CONFIG_PATH = path.join(__dirname, '..', 'image-sources.json');
function readSourcesConfig() {
const defaultConfig = { sources: [], fetchInterval: 2.5 };
try {
if (!fs.existsSync(CONFIG_PATH)) {
return defaultConfig;
}
const content = fs.readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(content);
return {
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
fetchInterval: typeof parsed.fetchInterval === 'number' ? parsed.fetchInterval : 2.5,
comments: parsed.comments || undefined
};
} catch (err) {
console.warn('Failed to read image-sources.json:', err.message);
return defaultConfig;
}
}
function writeSourcesConfig(config) {
const payload = {
...config,
comments: config.comments || {
sources: 'Array of image sources to fetch from',
name: 'Human-readable name for the source',
url: 'Full URL to the image',
tags: 'Array of tags to apply to fetched images (useful for ML training)',
enabled: 'Set to true to include this source in fetching',
fetchInterval: 'Minutes between fetch cycles. Examples: 0.033 = 2 seconds, 0.05 = 3 seconds, 0.083 = 5 seconds, 1 = 1 minute, 2.5 = 2.5 minutes (default)'
}
};
fs.writeFileSync(CONFIG_PATH, JSON.stringify(payload, null, 2));
}
function restartFetcherFromConfig(config) {
imageFetcher.stopFetcher();
const enabledSources = (config.sources || []).filter((source) => source.enabled);
const interval = config.fetchInterval || 2.5;
if (enabledSources.length > 0) {
imageFetcher.startFetcher(enabledSources, interval);
}
}
/** /**
* GET /api/images - Get all images with optional filtering * GET /api/images - Get all images with optional filtering
*/ */
@@ -271,4 +320,122 @@ router.get('/fetcher/status', (req, res) => {
res.json({ success: true, status }); res.json({ success: true, status });
}); });
/**
* GET /api/sources - Get image sources configuration
*/
router.get('/sources', (req, res) => {
const config = readSourcesConfig();
res.json({ success: true, config });
});
/**
* POST /api/sources - Add a new image source
*/
router.post('/sources', (req, res) => {
try {
const { name, url, tags = [], enabled = true } = req.body;
if (!name || !url) {
return res.status(400).json({ success: false, error: 'name and url are required' });
}
const config = readSourcesConfig();
const newSource = {
name: String(name).trim(),
url: String(url).trim(),
tags: Array.isArray(tags) ? tags.map(tag => String(tag).trim()).filter(Boolean) : [],
enabled: Boolean(enabled)
};
config.sources = [...(config.sources || []), newSource];
writeSourcesConfig(config);
restartFetcherFromConfig(config);
res.status(201).json({ success: true, config });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* PUT /api/sources/config - Update fetch interval
*/
router.put('/sources/config', (req, res) => {
try {
const { fetchInterval } = req.body;
const intervalValue = Number(fetchInterval);
if (!Number.isFinite(intervalValue) || intervalValue <= 0) {
return res.status(400).json({ success: false, error: 'fetchInterval must be a positive number' });
}
const config = readSourcesConfig();
config.fetchInterval = intervalValue;
writeSourcesConfig(config);
restartFetcherFromConfig(config);
res.json({ success: true, config });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* PUT /api/sources/:index - Update an existing source
*/
router.put('/sources/:index', (req, res) => {
try {
const index = Number(req.params.index);
if (!Number.isInteger(index)) {
return res.status(400).json({ success: false, error: 'Invalid source index' });
}
const config = readSourcesConfig();
if (!config.sources || !config.sources[index]) {
return res.status(404).json({ success: false, error: 'Source not found' });
}
const current = config.sources[index];
const updated = {
...current,
name: req.body.name !== undefined ? String(req.body.name).trim() : current.name,
url: req.body.url !== undefined ? String(req.body.url).trim() : current.url,
tags: Array.isArray(req.body.tags)
? req.body.tags.map(tag => String(tag).trim()).filter(Boolean)
: current.tags,
enabled: req.body.enabled !== undefined ? Boolean(req.body.enabled) : current.enabled
};
config.sources[index] = updated;
writeSourcesConfig(config);
restartFetcherFromConfig(config);
res.json({ success: true, config });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* DELETE /api/sources/:index - Remove a source
*/
router.delete('/sources/:index', (req, res) => {
try {
const index = Number(req.params.index);
const config = readSourcesConfig();
if (!Number.isInteger(index) || !config.sources || !config.sources[index]) {
return res.status(404).json({ success: false, error: 'Source not found' });
}
config.sources.splice(index, 1);
writeSourcesConfig(config);
restartFetcherFromConfig(config);
res.json({ success: true, config });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
module.exports = router; module.exports = router;

View File

@@ -11,6 +11,7 @@ const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com';
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// Initialize database // Initialize database