feat: Implement image fetching and storage system
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled

- Add image-fetcher module for downloading and saving images from various sources.
- Create storage module for managing image files, including downloading, verifying integrity, and cleaning up orphaned files.
- Develop gallery HTML page for displaying images with sorting and filtering options.
- Set up RESTful API routes for image management, including fetching, adding tags, and deleting images.
- Introduce setup script for initializing the database and configuring image sources.
- Implement a batch process for verifying image integrity and cleaning up old images.
- Add setup batch script for easy installation and configuration of the image storage system.
This commit is contained in:
2026-02-12 13:13:36 -05:00
parent ea6cc3fc85
commit 9c72b00b1b
19 changed files with 3004 additions and 71 deletions

View File

@@ -3,4 +3,8 @@
node_modules/
*.log
README.md
IMAGE_STORAGE_GUIDE.md
.gitignore
data/
setup.bat
scripts/deploy.*

412
DOCKER_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,412 @@
# 🐳 Docker Deployment Guide for Image Storage
This guide covers deployment of the HomeBase image storage system in Docker on your home lab.
## Prerequisites
- Docker and Docker Compose installed on your home lab server (beepc)
- SSH access configured (required for deploy script)
- PowerShell 5+ or PowerShell Core for running deploy scripts
- Network access from your dev machine to the home lab
## Quick Deployment
### 1. Deploy to Home Lab
From your local machine, run:
```powershell
.\scripts\deploy.ps1
```
This script will:
- Copy all project files to your home lab server (spencer@beepc)
- Build the Docker image
- Create and start the container with persistent storage
- Set up systemd service for auto-start
### 2. After Deployment
The container will automatically:
- Create the SQLite database
- Create image storage directories
- Start the image fetcher (if configured)
- Be available at `http://homebase.sketchferret.com`
## Docker Setup Details
### Persistent Storage
Images are stored in a Docker volume called `homebase-data`:
```yaml
volumes:
- homebase-data:/app/data
```
This ensures:
- Database persists across container restarts
- Image files are not lost on container recreation
- Data survives container updates
### Viewing Container Logs
```bash
# SSH into home lab
ssh spencer@beepc
# View container logs
docker compose -f ~/homebase/docker-compose.yml logs -f
# Or use systemd
journalctl -u homebase -f
```
### Container Management
```bash
# Stop container
docker compose -f ~/homebase/docker-compose.yml down
# Rebuild and restart
docker compose -f ~/homebase/docker-compose.yml up -d --build
# Check status
docker compose -f ~/homebase/docker-compose.yml ps
# View stored images
docker exec homebase ls -lh /app/data/images/
```
## Configuration in Docker
### Image Sources (image-sources.json)
After deployment, edit the configuration on the remote server:
```bash
ssh spencer@beepc
nano ~/homebase/image-sources.json
```
Then restart the container:
```bash
cd ~/homebase
docker compose restart app
```
### Environment Variables
Edit `docker-compose.yml` to customize:
```yaml
environment:
- NODE_ENV=production
- PORT=3001
- DOMAIN=homebase.sketchferret.com
```
## Managing Data
### Backup Database
The SQLite database is stored in the Docker volume at `/app/data/homebase.db`
```bash
# SSH into server
ssh spencer@beepc
# Backup database
docker run --rm -v homebase-data:/data \
-v ~/backups:/backup \
alpine cp /data/homebase.db /backup/homebase.db.$(date +%Y%m%d)
# Backup images
docker run --rm -v homebase-data:/data \
-v ~/backups:/backup \
alpine tar czf /backup/images.$(date +%Y%m%d).tar.gz /data/images/
```
### Restore Database
```bash
docker run --rm -v homebase-data:/data \
-v ~/backups:/backup \
alpine cp /backup/homebase.db.20260211 /data/homebase.db
# Restart container
docker restart homebase
```
### Clean Up Old Images
```bash
# SSH into server
ssh spencer@beepc
# Connect to running container
docker exec -it homebase node -e "
const db = require('./lib/database');
db.initializeDatabase().then(async () => {
const deleted = await db.cleanupOldImages(30);
console.log('Deleted', deleted, 'old images');
process.exit(0);
}).catch(err => {
console.error(err);
process.exit(1);
});
"
```
## Troubleshooting
### Container won't start
```bash
ssh spencer@beepc
cd ~/homebase
# Check logs
docker compose logs app
# Rebuild
docker compose up -d --build
# Check again
docker compose ps
```
### Permission errors
The container runs as the `node` user. Check logs:
```bash
docker compose exec app ls -la /app/data/
```
All files should be owned by `node`.
### Can't access via domain
Check that the service is running:
```bash
ssh spencer@beepc
# Check systemd
systemctl status homebase
# Check Docker
docker ps | grep homebase
# Test port
curl -i http://localhost:3001/health
```
### Database is locked
This usually means two processes are accessing the database simultaneously:
```bash
# Check if multiple containers are running
docker ps -a | grep homebase
# Stop all containers
docker compose down
# Verify only one is running
docker compose up -d
```
## Updating the Deployment
### Update Code
After making changes locally:
```powershell
cd c:\Users\srene\Sources\HomeBase
git commit -am "Add new features"
git push
# Deploy to home lab
.\scripts\deploy.ps1
```
### Update Dependencies
If you update `package.json`:
```powershell
# From your local machine
.\scripts\deploy.ps1
# The deploy script will:
# 1. Copy new package.json
# 2. Rebuild the Docker image (npm install runs here)
# 3. Restart the container
```
### Manual Updates
SSH into the server and run:
```bash
cd ~/homebase
# Pull latest code
git pull # If using git
# Or manually copy files via SCP
# Then rebuild:
docker compose up -d --build
```
## Systemd Service
The deployment script sets up a systemd service:
```bash
# Check service
systemctl status homebase
# Start service
sudo systemctl start homebase
# Stop service
sudo systemctl stop homebase
# View logs
journalctl -u homebase -f
```
Service file location: `/etc/systemd/system/homebase.service`
## Volume Management
### List volumes
```bash
docker volume ls | grep homebase
```
### Inspect volume
```bash
docker volume inspect homebase-data
```
Shows the mount point on the host server.
### Manual mount (for recovery)
```bash
docker run -it --rm -v homebase-data:/data alpine sh
```
Then you can inspect files at `/data/`
## Performance Optimization
### Monitor Storage Usage
```bash
curl http://homebase.sketchferret.com/api/stats
```
### Cleanup Recommendations
- **Weekly**: Run cleanup for images older than 7 days
- **Monthly**: Run verification to check corruption
- **Quarterly**: Full database optimization
```bash
# SSH into server
ssh spencer@beepc
docker exec homebase node -e "
const db = require('./lib/database');
const storage = require('./lib/storage');
db.initializeDatabase().then(async () => {
console.log('Running cleanup...');
const deleted = await db.cleanupOldImages(7);
const orphaned = await storage.cleanupOrphanedFiles(db.db);
console.log('Deleted:', deleted, 'old images');
console.log('Removed:', orphaned.cleaned, 'orphaned files');
process.exit(0);
}).catch(err => {
console.error('Error:', err);
process.exit(1);
});
"
```
## Network & Firewall
### Open Ports
Ensure port 3001 is accessible on your home lab network:
```bash
# Check if port is listening
ssh spencer@beepc
netstat -tlnp | grep 3001
# Or use Docker
docker port homebase
```
### Domain Configuration
The app is configured to run on `homebase.sketchferret.com`. Ensure:
- DNS resolves to your home lab IP
- Firewall allows traffic on port 3001
- Your home lab router has port forwarding (if external access needed)
## Debugging
### Enable detailed logging
```bash
# SSH into server
ssh spencer@beepc
# View container logs with timestamps
docker compose logs -f --timestamps app
# Or view the last 100 lines
docker compose logs --tail=100 app
```
### Access container shell
```bash
docker exec -it homebase sh
cd /app
node setup.js config
```
### Database inspection
```bash
# Access SQLite directly
docker exec -it homebase sqlite3 /app/data/homebase.db
# In sqlite prompt:
sqlite> SELECT COUNT(*) FROM images;
sqlite> SELECT * FROM tags;
sqlite> .quit
```
## Best Practices
1. **Regular Backups**: Back up the database volume weekly
2. **Log Monitoring**: Check logs regularly for errors
3. **Storage Limits**: Monitor storage and clean up old images
4. **Configuration**: Keep `image-sources.json` updated with working URLs
5. **Testing**: Test image fetching after configuration changes
---
For API usage, see [IMAGE_STORAGE_GUIDE.md](IMAGE_STORAGE_GUIDE.md)

View File

@@ -8,8 +8,13 @@ RUN npm install --omit=dev
COPY . .
# Ensure proper permissions for node user
RUN chown -R node:node /app
# Create data directory for persistent storage
RUN mkdir -p /app/data/images && \
chown -R node:node /app && \
chmod -R 755 /app/data
# Install avahi for mDNS support (.local hostname resolution)
RUN apk add --no-cache avahi avahi-tools libc6-compat
EXPOSE 3001

435
IMAGE_STORAGE_GUIDE.md Normal file
View File

@@ -0,0 +1,435 @@
# 📸 Image Storage System Guide
This guide covers the long-term image storage solution integrated into HomeBase. It provides reliable, organized storage for images pulled from web services with built-in corruption detection and ML-friendly tagging.
## Features
- **Automatic Image Fetching**: Pull images from web services every 2-3 minutes
- **Corruption Detection**: SHA256 checksums verify data integrity
- **Tag-Based Organization**: Tag images for machine learning model training
- **Efficient Storage**: File-based storage with SQLite metadata database
- **RESTful API**: Complete API for image management and queries
- **Scalable**: Designed to handle thousands of images
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Image Fetcher Service │
│ (Runs every 2-3 minutes, pull from web service) │
└────────┬────────────────────────────────────────────┘
├─────┬──────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
Download Hash Store File Insert Metadata
Image (SHA256) (data/images) (SQLite)
└──────────────────────────────┘
┌─────────▼──────────┐
│ REST API Endpoints │
│ - List Images │
│ - Add Tags │
│ - Search/Filter │
│ - Verify Integrity │
└────────────────────┘
```
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
### 2. Initialize the System
```bash
node setup.js init
```
This will:
- Create the SQLite database
- Set up data directory structure
- Load configuration
- Test the system
### 3. Configure Image Sources
Edit `image-sources.json`:
```json
{
"sources": [
{
"name": "Webcam Feed",
"url": "https://your-service.com/image.jpg",
"tags": ["webcam", "monitoring"],
"enabled": true
}
],
"fetchInterval": 0.033
}
```
**Fetch Intervals** (edit `fetchInterval` to customize):
- `0.0167` = 1 second
- `0.033` = 2 seconds (recommended for fast updates)
- `0.05` = 3 seconds
- `2.5` = 2.5 minutes (original default)
### 4. Start the Server
```bash
npm start
```
The system will automatically start fetching images at the configured interval.
## Configuration
### image-sources.json
```json
{
"sources": [
{
"name": "Example Source",
"url": "https://example.com/image.jpg",
"tags": ["tag1", "tag2"],
"enabled": true
}
],
"fetchInterval": 0.033
}
```
**Options:**
- `fetchInterval`: **Minutes** between fetch cycles. Use decimals for sub-minute intervals:
- `0.0167` = 1 second
- `0.033` = 2 seconds
- `0.05` = 3 seconds
- `0.167` = 10 seconds
- `1` = 1 minute
- `2.5` = 2.5 minutes (original default)
## API Endpoints
### List Images
```bash
GET /api/images?page=1&pageSize=50&sort=fetched_at&order=DESC
GET /api/images?tag=webcam # Filter by tag
GET /api/images?sourceUrl=https://... # Filter by source
```
**Response:**
```json
{
"success": true,
"images": [
{
"id": 1,
"filename": "image_1234567890_abc123.jpg",
"source_url": "https://...",
"filesize": 102400,
"file_hash": "sha256hash",
"fetched_at": "2023-01-01T12:00:00Z",
"tags": ["webcam", "monitoring"]
}
],
"pagination": {
"page": 1,
"pageSize": 50,
"total": 240,
"pages": 5
}
}
```
### Get Image Details
```bash
GET /api/images/{id}
```
### Download Image
```bash
GET /api/images/{id}/download
```
### Fetch New Image
```bash
POST /api/images
Content-Type: application/json
{
"source_url": "https://example.com/image.jpg",
"tags": ["tag1", "tag2"]
}
```
### Add Tags to Image
```bash
POST /api/images/{id}/tags
Content-Type: application/json
{
"tags": ["newtag1", "newtag2"]
}
```
### List All Tags
```bash
GET /api/tags
```
**Response:**
```json
{
"success": true,
"tags": ["webcam", "monitoring", "test", "example"]
}
```
### Storage Statistics
```bash
GET /api/stats
```
**Response:**
```json
{
"success": true,
"stats": {
"imageCount": 240,
"totalSize": 24576000,
"totalSizeGB": "0.02",
"fileCount": 240
}
}
```
### Verify Image Integrity
```bash
POST /api/verify
```
This checks all images for corruption using their stored checksums.
### Cleanup Old Images
```bash
POST /api/cleanup
Content-Type: application/json
{
"daysOld": 30
}
```
### Delete Image
```bash
DELETE /api/images/{id}
```
## Corruption Detection
The system uses SHA256 checksums to detect corruption:
1. **Storage**: When an image is saved, its SHA256 hash is calculated and stored
2. **Verification**: The `/api/verify` endpoint re-hashes all files and compares with stored hashes
3. **Marking**: Corrupted images are marked in the database and excluded from queries
4. **Recovery**: Corrupted files can be re-fetched using the source URL
### Manual Verification
```bash
curl -X POST http://localhost:3001/api/verify
```
## Tagging for ML Training
Tags are essential for organizing training datasets:
```bash
# Add images with training tags
POST /api/images
{
"source_url": "https://...",
"tags": ["dataset_v1", "labeled", "weather-sunny"]
}
# Query all images with specific tag
GET /api/images?tag=weather-sunny
# Get tag statistics
GET /api/tags
```
## Database Schema
### Images Table
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| filename | TEXT | Unique filename |
| source_url | TEXT | Original image URL |
| file_path | TEXT | Local file path |
| filesize | INTEGER | File size in bytes |
| file_hash | TEXT | SHA256 hash |
| mime_type | TEXT | Content type |
| fetched_at | DATETIME | When image was fetched |
| is_corrupted | BOOLEAN | Corruption flag |
### Tags Table
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| image_id | INTEGER | Foreign key to images |
| tag | TEXT | Tag text |
| created_at | DATETIME | When tag was added |
## File Structure
```
homebase/
├── server.js # Main server
├── package.json # Dependencies
├── setup.js # Setup script
├── image-sources.json # Configuration
├── lib/
│ ├── database.js # SQLite operations
│ ├── storage.js # File storage operations
│ └── image-fetcher.js # Image fetching service
├── routes/
│ └── images.js # API routes
└── data/ # Created at runtime
├── homebase.db # SQLite database
└── images/ # Stored image files
```
## Maintenance
### Manual Setup
```bash
# Initialize system
node setup.js init
# Test fetch
node setup.js test https://example.com/image.jpg tag1,tag2
# View configuration
node setup.js config
```
### Regular Tasks
```bash
# Daily: Verify integrity
curl -X POST http://localhost:3001/api/verify
# Weekly: Cleanup old images (30+ days)
curl -X POST http://localhost:3001/api/cleanup -H "Content-Type: application/json" -d '{"daysOld": 30}'
# Monitor storage
curl http://localhost:3001/api/stats
```
## Performance Tips
1. **Pagination**: Use `pageSize=50` or less for large datasets
2. **Tagging**: Use consistent tag names for easier filtering
3. **Cleanup**: Run cleanup weekly to manage storage
4. **Verification**: Run verification monthly to detect issues early
## Troubleshooting
### No images fetching?
1. Check `image-sources.json` - ensure `enabled: true`
2. Verify URL is accessible
3. Check server logs for errors
4. Test fetch: `node setup.js test https://url.jpg`
### High storage usage?
```bash
# Check statistics
curl http://localhost:3001/api/stats
# Cleanup images older than 7 days
curl -X POST http://localhost:3001/api/cleanup \
-H "Content-Type: application/json" \
-d '{"daysOld": 7}'
```
### Corrupted files detected?
```bash
# Verify all images
curl -X POST http://localhost:3001/api/verify
# Get list of corrupted images
curl 'http://localhost:3001/api/images?corrupted=true'
# Re-fetch if source still available
POST /api/images with same source_url
```
## Advanced Usage
### Export Dataset for ML Training
```bash
# Get all images with specific tag
curl 'http://localhost:3001/api/images?tag=weather-sunny&pageSize=999' \
-o dataset.json
```
### Monitor Fetch Status
```bash
curl http://localhost:3001/api/fetcher/status
```
### Batch Operations
```bash
# Add tags to multiple images (via API loop)
for image_id in 1 2 3 4 5; do
curl -X POST http://localhost:3001/api/images/$image_id/tags \
-H "Content-Type: application/json" \
-d '{"tags": ["batch-import"]}'
done
```
## Security Considerations
1. **Database Access**: SQLite database is file-based; protect with filesystem permissions
2. **Image Storage**: Protect `data/images` directory from unauthorized access
3. **API Security**: Consider adding authentication for production use
4. **File Validation**: System validates MIME types and file sizes
## Performance Metrics
- **Fetch Time**: ~1-5 seconds per image (network dependent)
- **Database Queries**: <100ms for typical queries
- **Verification**: ~50ms per image
- **Storage**: ~1KB overhead per image in database
---
For more information, check the API documentation or run `node setup.js help`.

190
README.md
View File

@@ -1,13 +1,18 @@
# HomeBase
Simple self-hosted Node.js Docker application with automated SSH deployment.
Self-hosted Node.js Docker application with automated SSH deployment and **long-term image storage** with corruption detection and tagging for ML training.
## Features
- 🚀 Simple Express.js server
- 🐳 Docker containerized
- 🚀 Express.js server
- 🐳 Docker containerized with persistent volumes
- 🔄 Automated deployment via SSH
- ✅ Health check endpoint
- 📸 **Long-term image storage** with SQLite database
- 🏷️ **Tag-based organization** for machine learning datasets
- 🛡️ **Corruption detection** using SHA256 checksums
- 🔍 **RESTful API** for image management
- 🧪 **Automatic fetching** every 2-3 minutes from web services
- ✅ Health check endpoints
- 🔐 SSH key-based authentication
## Prerequisites
@@ -30,53 +35,70 @@ On the remote server (beepc):
# Install dependencies
npm install
# Initialize the image storage system
node setup.js init
# Configure image sources (edit image-sources.json)
# Set "enabled": true for sources you want to fetch from
# Run locally
npm start
# Visit http://localhost:3001
```
### Image Storage Features
Once running, manage images via the REST API:
```bash
# List all images
curl http://localhost:3001/api/images
# Add an image manually
curl -X POST http://localhost:3001/api/images \
-H "Content-Type: application/json" \
-d '{"source_url":"https://example.com/image.jpg","tags":["test"]}'
# Get storage statistics
curl http://localhost:3001/api/stats
# Verify all images for corruption
curl -X POST http://localhost:3001/api/verify
```
📖 **Full documentation:**
- [📸 Image Storage Guide](IMAGE_STORAGE_GUIDE.md) - Complete API reference, configuration, and usage
- [🐳 Docker Deployment Guide](DOCKER_DEPLOYMENT.md) - Deployment to home lab with persistent storage
### Deployment
### Option 1: Manual Deployment (Windows)
### Quick Deployment to Home Lab (PowerShell)
```bash
# Run the deployment script from project root
.\scripts\deploy.bat
```
### Option 2: Manual Deployment (Linux/Mac)
```bash
# Make script executable
chmod +x scripts/deploy.sh
# Run deployment
bash scripts/deploy.sh
```
### Option 3: Manual Deployment (PowerShell)
From your local Windows machine:
```powershell
# Run the deployment script from project root
# Deploy to home lab (beepc)
.\scripts\deploy.ps1
```
### Option 4: Automated Deployment (GitHub Actions)
This will:
1. Copy all project files to your home lab server
2. Build the Docker image
3. Create and start the container with persistent storage
4. Set up systemd service for auto-start
1. Add your SSH private key to GitHub Secrets:
- Go to your repository Settings > Secrets and variables > Actions
- Add a new secret named `SSH_PRIVATE_KEY`
- Paste your SSH private key content
The app will be available at `http://homebase.sketchferret.com`
2. Push to main branch:
```bash
git add .
git commit -m "Initial commit"
git push origin main
```
### Persistent Data Storage
3. The app will automatically deploy to spencer@beepc
Images and database are stored in a Docker volume (`homebase-data`) that persists across container restarts and updates.
For detailed deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)
### Option 2: Manual Deployment
Manual deployment steps are documented in [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md#manual-updates)
## SSH Setup
@@ -114,28 +136,62 @@ sudo apt-get install docker-compose-plugin
## API Endpoints
- `GET /` - Main endpoint, returns app status
### Status & Health
- `GET /api/status` - App status and version
- `GET /health` - Health check endpoint
### Image Management
- `GET /api/images` - List all images (with pagination & filtering)
- `GET /api/images/{id}` - Get image details
- `GET /api/images/{id}/download` - Download image file
- `POST /api/images` - Fetch and save new image
- `DELETE /api/images/{id}` - Delete image
### Tagging
- `POST /api/images/{id}/tags` - Add tags to image
- `GET /api/tags` - List all available tags
### Administration
- `GET /api/stats` - Storage statistics
- `POST /api/verify` - Verify all images for corruption
- `POST /api/cleanup` - Clean up old images
- `GET /api/fetcher/status` - Image fetcher status
See [IMAGE_STORAGE_GUIDE.md](IMAGE_STORAGE_GUIDE.md) for detailed API documentation.
## Project Structure
```
HomeBase/
├── scripts/
│ ├── deploy.ps1 # PowerShell deployment script
│ ├── deploy.sh # Bash deployment script
│ └── deploy.bat # Windows batch deployment script
│ ├── deploy.ps1 # PowerShell deployment script
│ ├── deploy.sh # Bash deployment script
│ └── deploy.bat # Windows batch deployment script
├── lib/
│ ├── database.js # SQLite operations & schema
│ ├── storage.js # File storage & checksums
│ └── image-fetcher.js # Image fetching service
├── routes/
│ └── images.js # Image API endpoints
├── public/
│ ├── index.html
│ ├── app.js
│ └── styles.css
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions workflow
├── .dockerignore # Docker ignore file
├── .gitignore # Git ignore file
├── Dockerfile # Docker configuration
├── docker-compose.yml # Docker Compose configuration
├── homebase.service # Systemd service file
├── package.json # Node.js dependencies
├── server.js # Express server
── README.md # This file
│ └── deploy.yml # GitHub Actions workflow
├── .dockerignore # Docker ignore file
├── .gitignore # Git ignore file
├── Dockerfile # Docker configuration
├── docker-compose.yml # Docker Compose with volumes
├── homebase.service # Systemd service file
├── package.json # Node.js dependencies
├── server.js # Express server
── setup.js # Setup & CLI tool
├── image-sources.json # Image source configuration
├── IMAGE_STORAGE_GUIDE.md # Complete image storage docs
├── DOCKER_DEPLOYMENT.md # Docker deployment guide
└── README.md # This file
```
## Deployment Process
@@ -212,16 +268,44 @@ ssh spencer@beepc "sudo systemctl restart homebase"
ssh spencer@beepc "sudo systemctl disable homebase"
```
## Environment Variables
## Configuration
You can customize settings by modifying [docker-compose.yml](docker-compose.yml):
### Image Sources
Edit `image-sources.json` to configure which image URLs to fetch from:
```json
{
"sources": [
{
"name": "Webcam Feed",
"url": "https://your-service.com/image.jpg",
"tags": ["webcam", "monitoring"],
"enabled": true
}
],
"fetchInterval": 0.033
}
```
**Fetch Intervals** - Edit `fetchInterval` (in minutes):
- `0.0167` = 1 second
- `0.033` = 2 seconds (default, recommended for steady updates)
- `0.05` = 3 seconds
- `1` = 1 minute
- `2.5` = 2.5 minutes (original default)
The fetcher will automatically start pulling images from enabled sources at this interval.
### Environment Variables
Customize settings in [docker-compose.yml](docker-compose.yml):
```yaml
environment:
- PORT=3001 # Internal port (reverse proxy maps to standard HTTP)
- DOMAIN=homebase.sketchferret.com # Your domain
ports:
- "3001:3001" # Port mapping for reverse proxy backend
- PORT=3001
- DOMAIN=homebase.sketchferret.com
- NODE_ENV=production
```
## Security Notes

View File

@@ -3,9 +3,19 @@ services:
build: .
container_name: homebase
restart: unless-stopped
ports:
- "3001:3001"
# Use host network to access both local network and Tailscale
# Also inherits host's DNS for .local mDNS hostname resolution
network_mode: host
environment:
- NODE_ENV=production
- PORT=3001
- DOMAIN=homebase.sketchferret.com
volumes:
# Persist database and image files
- homebase-data:/app/data
# Mount host's resolv.conf for mDNS support
- /etc/resolv.conf:/etc/resolv.conf:ro
volumes:
homebase-data:
driver: local

19
image-sources.json Normal file
View File

@@ -0,0 +1,19 @@
{
"sources": [
{
"name": "Bedroom Exterior",
"url": "http://192.168.254.66:8080/",
"tags": ["lake", "road"],
"enabled": true
}
],
"fetchInterval": 0.033,
"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)"
}
}

342
lib/database.js Normal file
View File

@@ -0,0 +1,342 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, '..', 'data', 'homebase.db');
// Ensure data directory exists
const dataDir = path.dirname(DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Database connection error:', err);
} else {
console.log('Connected to SQLite database at', DB_PATH);
}
});
// Enable foreign keys
db.run('PRAGMA foreign_keys = ON');
/**
* Initialize database schema
*/
async function initializeDatabase() {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Images table
db.run(
`CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
source_url TEXT NOT NULL,
file_path TEXT NOT NULL,
filesize INTEGER,
file_hash TEXT NOT NULL,
mime_type TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_corrupted BOOLEAN DEFAULT 0
)`,
(err) => {
if (err) reject(err);
}
);
// Tags table
db.run(
`CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
tag TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
UNIQUE(image_id, tag)
)`,
(err) => {
if (err) reject(err);
}
);
// Create indexes for better query performance
db.run('CREATE INDEX IF NOT EXISTS idx_images_source_url ON images(source_url)');
db.run('CREATE INDEX IF NOT EXISTS idx_images_fetched_at ON images(fetched_at)');
db.run('CREATE INDEX IF NOT EXISTS idx_images_file_hash ON images(file_hash)');
db.run('CREATE INDEX IF NOT EXISTS idx_tags_image_id ON tags(image_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)', (err) => {
if (err) {
reject(err);
} else {
console.log('Database initialized successfully');
resolve();
}
});
});
});
}
/**
* Insert a new image record
*/
function insertImage(imageData) {
return new Promise((resolve, reject) => {
const { filename, source_url, file_path, filesize, file_hash, mime_type } = imageData;
db.run(
`INSERT INTO images (filename, source_url, file_path, filesize, file_hash, mime_type)
VALUES (?, ?, ?, ?, ?, ?)`,
[filename, source_url, file_path, filesize, file_hash, mime_type],
function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID });
}
}
);
});
}
/**
* Add tags to an image
*/
function addTags(imageId, tags) {
return new Promise((resolve, reject) => {
if (!Array.isArray(tags) || tags.length === 0) {
resolve();
return;
}
db.serialize(() => {
const stmt = db.prepare(
'INSERT OR IGNORE INTO tags (image_id, tag) VALUES (?, ?)'
);
let completed = 0;
let hasError = false;
tags.forEach((tag) => {
stmt.run([imageId, tag], (err) => {
if (err && !hasError) {
hasError = true;
stmt.finalize();
reject(err);
} else {
completed++;
if (completed === tags.length) {
stmt.finalize((err) => {
if (err) reject(err);
else resolve();
});
}
}
});
});
});
});
}
/**
* Mark image as corrupted
*/
function markAsCorrupted(imageId) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE images SET is_corrupted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[imageId],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
/**
* Get image by ID
*/
function getImage(imageId) {
return new Promise((resolve, reject) => {
db.get(
'SELECT * FROM images WHERE id = ?',
[imageId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
/**
* Get all images with optional filtering
*/
function getImages(options = {}) {
return new Promise((resolve, reject) => {
const { tag, sourceUrl, limit = 100, offset = 0, sortBy = 'fetched_at', order = 'DESC' } = options;
let query = 'SELECT DISTINCT i.* FROM images i';
let params = [];
if (tag) {
query += ' INNER JOIN tags t ON i.id = t.image_id';
}
query += ' WHERE i.is_corrupted = 0';
if (tag) {
query += ' AND t.tag = ?';
params.push(tag);
}
if (sourceUrl) {
query += ' AND i.source_url = ?';
params.push(sourceUrl);
}
query += ` ORDER BY i.${sortBy} ${order} LIMIT ? OFFSET ?`;
params.push(limit, offset);
db.all(query, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
/**
* Get tags for an image
*/
function getImageTags(imageId) {
return new Promise((resolve, reject) => {
db.all(
'SELECT tag FROM tags WHERE image_id = ? ORDER BY tag',
[imageId],
(err, rows) => {
if (err) reject(err);
else resolve((rows || []).map(r => r.tag));
}
);
});
}
/**
* Get all unique tags
*/
function getAllTags() {
return new Promise((resolve, reject) => {
db.all(
'SELECT DISTINCT tag FROM tags ORDER BY tag',
(err, rows) => {
if (err) reject(err);
else resolve((rows || []).map(r => r.tag));
}
);
});
}
/**
* Get image count
*/
function getImageCount(tag = null) {
return new Promise((resolve, reject) => {
let query = 'SELECT COUNT(*) as count FROM images WHERE is_corrupted = 0';
let params = [];
if (tag) {
query = `SELECT COUNT(DISTINCT i.id) as count FROM images i
INNER JOIN tags t ON i.id = t.image_id
WHERE i.is_corrupted = 0 AND t.tag = ?`;
params.push(tag);
}
db.get(query, params, (err, row) => {
if (err) reject(err);
else resolve(row?.count || 0);
});
});
}
/**
* Delete image (soft delete - keeps file but marks for cleanup)
*/
function deleteImage(imageId) {
return new Promise((resolve, reject) => {
db.run(
'DELETE FROM images WHERE id = ?',
[imageId],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
/**
* Clean up old images (older than specified days)
*/
function cleanupOldImages(daysOld = 30) {
return new Promise((resolve, reject) => {
db.run(
`DELETE FROM images WHERE fetched_at < datetime('now', '-' || ? || ' days')`,
[daysOld],
function(err) {
if (err) reject(err);
else resolve(this.changes);
}
);
});
}
/**
* Get images by hash (detect duplicates)
*/
function getImagesByHash(hash) {
return new Promise((resolve, reject) => {
db.all(
'SELECT * FROM images WHERE file_hash = ? AND is_corrupted = 0',
[hash],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
}
/**
* Close database connection
*/
function closeDatabase() {
return new Promise((resolve, reject) => {
db.close((err) => {
if (err) reject(err);
else {
console.log('Database connection closed');
resolve();
}
});
});
}
module.exports = {
db,
initializeDatabase,
insertImage,
addTags,
markAsCorrupted,
getImage,
getImages,
getImageTags,
getAllTags,
getImageCount,
deleteImage,
cleanupOldImages,
getImagesByHash,
closeDatabase
};

162
lib/image-fetcher.js Normal file
View File

@@ -0,0 +1,162 @@
const storage = require('./storage');
const database = require('./database');
const axios = require('axios');
let fetchInterval = null;
let isRunning = false;
/**
* Fetch a single image from URL
*/
async function fetchImage(sourceUrl, tags = []) {
try {
console.log(`[Image Fetcher] Fetching from: ${sourceUrl}`);
// Download and save image
const downloadResult = await storage.downloadAndSaveImage(sourceUrl);
if (!downloadResult.success) {
console.error(`[Image Fetcher] Failed to download: ${downloadResult.error}`);
return { success: false, error: downloadResult.error };
}
// Insert into database
const imageRecord = await database.insertImage({
filename: downloadResult.filename,
source_url: sourceUrl,
file_path: downloadResult.file_path,
filesize: downloadResult.filesize,
file_hash: downloadResult.file_hash,
mime_type: downloadResult.mime_type
});
// Add tags if provided
if (tags && tags.length > 0) {
await database.addTags(imageRecord.id, tags);
}
console.log(`[Image Fetcher] Saved image: ${downloadResult.filename} (ID: ${imageRecord.id})`);
return {
success: true,
imageId: imageRecord.id,
filename: downloadResult.filename,
filesize: downloadResult.filesize
};
} catch (err) {
console.error('[Image Fetcher] Error:', err.message);
return { success: false, error: err.message };
}
}
/**
* Verify all images in database for corruption
*/
async function verifyAllImages() {
try {
console.log('[Image Fetcher] Starting verification of all images...');
const images = await database.getImages({ limit: 10000 });
let corruptCount = 0;
for (const image of images) {
const verification = await storage.verifyImageIntegrity(image.file_path, image.file_hash);
if (!verification.valid) {
console.warn(`[Image Fetcher] Corrupted image detected: ${image.filename} - ${verification.reason}`);
await database.markAsCorrupted(image.id);
corruptCount++;
}
}
if (corruptCount > 0) {
console.log(`[Image Fetcher] Found and marked ${corruptCount} corrupted images`);
} else {
console.log('[Image Fetcher] Verification complete - all images intact');
}
return { checked: images.length, corrupted: corruptCount };
} catch (err) {
console.error('[Image Fetcher] Verification error:', err.message);
}
}
/**
* Fetch from multiple sources
*/
async function fetchFromMultipleSources(sources) {
console.log(`[Image Fetcher] Fetching from ${sources.length} source(s)...`);
const results = [];
for (const source of sources) {
const sourceUrl = typeof source === 'string' ? source : source.url;
const tags = source.tags || [];
const result = await fetchImage(sourceUrl, tags);
results.push(result);
}
return results;
}
/**
* Start periodic image fetching
*/
function startFetcher(sources, intervalMinutes = 2.5) {
if (isRunning) {
console.log('[Image Fetcher] Fetcher already running');
return;
}
isRunning = true;
const intervalMs = intervalMinutes * 60 * 1000;
console.log(`[Image Fetcher] Starting fetcher with ${intervalMinutes} minute interval`);
console.log(`[Image Fetcher] Will fetch from ${sources.length} source(s)`);
// Do initial fetch immediately
fetchFromMultipleSources(sources);
// Then set up interval
fetchInterval = setInterval(() => {
fetchFromMultipleSources(sources);
}, intervalMs);
return {
status: 'running',
interval: intervalMinutes,
sources: sources.length,
nextFetch: new Date(Date.now() + intervalMs)
};
}
/**
* Stop the fetcher
*/
function stopFetcher() {
if (fetchInterval) {
clearInterval(fetchInterval);
fetchInterval = null;
isRunning = false;
console.log('[Image Fetcher] Fetcher stopped');
}
}
/**
* Get fetcher status
*/
function getStatus() {
return {
running: isRunning,
nextFetch: fetchInterval ? 'scheduled' : 'not running'
};
}
module.exports = {
fetchImage,
fetchFromMultipleSources,
startFetcher,
stopFetcher,
verifyAllImages,
getStatus
};

244
lib/storage.js Normal file
View File

@@ -0,0 +1,244 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const axios = require('axios');
const IMAGES_DIR = path.join(__dirname, '..', 'data', 'images');
// Ensure images directory exists
if (!fs.existsSync(IMAGES_DIR)) {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
/**
* Calculate SHA256 hash of a file
*/
function calculateFileHash(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
/**
* Generate a unique filename
*/
function generateFilename(sourceUrl, timestamp = Date.now()) {
const hash = crypto.createHash('md5').update(sourceUrl + timestamp).digest('hex');
const ext = getFileExtension(sourceUrl);
return `image_${timestamp}_${hash}${ext}`;
}
/**
* Get file extension from URL
*/
function getFileExtension(url) {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const ext = path.extname(pathname);
// If no extension, try to get from content-type or use default
if (!ext || ext.length > 5) {
return '.jpg';
}
return ext;
} catch (err) {
return '.jpg';
}
}
/**
* Download an image and save it to storage
*/
async function downloadAndSaveImage(sourceUrl, options = {}) {
const { timeout = 30000, maxSize = 50 * 1024 * 1024 } = options;
try {
// Download image
const response = await axios({
url: sourceUrl,
method: 'GET',
responseType: 'arraybuffer',
timeout,
maxContentLength: maxSize,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
const filename = generateFilename(sourceUrl);
const filePath = path.join(IMAGES_DIR, filename);
const fileBuffer = Buffer.from(response.data);
const filesize = fileBuffer.length;
const file_hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const mime_type = response.headers['content-type'] || 'image/jpeg';
// Save file
fs.writeFileSync(filePath, fileBuffer);
return {
filename,
file_path: filePath,
relative_path: path.relative(path.join(__dirname, '..'), filePath),
filesize,
file_hash,
mime_type,
source_url: sourceUrl,
success: true
};
} catch (err) {
console.error(`Failed to download image from ${sourceUrl}:`, err.message);
return {
source_url: sourceUrl,
success: false,
error: err.message
};
}
}
/**
* Verify image integrity using stored hash
*/
async function verifyImageIntegrity(filePath, expectedHash) {
try {
if (!fs.existsSync(filePath)) {
return { valid: false, reason: 'File does not exist' };
}
const actualHash = await calculateFileHash(filePath);
if (actualHash !== expectedHash) {
return { valid: false, reason: 'Hash mismatch', expectedHash, actualHash };
}
return { valid: true };
} catch (err) {
return { valid: false, reason: err.message };
}
}
/**
* Delete image file
*/
function deleteImageFile(filePath) {
return new Promise((resolve, reject) => {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
resolve(true);
} else {
resolve(false);
}
} catch (err) {
reject(err);
}
});
}
/**
* Get image file as buffer
*/
function getImageBuffer(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
/**
* Get storage statistics
*/
function getStorageStats() {
return new Promise((resolve, reject) => {
try {
let totalSize = 0;
let fileCount = 0;
if (!fs.existsSync(IMAGES_DIR)) {
resolve({ totalSize: 0, fileCount: 0, dataDir: IMAGES_DIR });
return;
}
const files = fs.readdirSync(IMAGES_DIR);
files.forEach((file) => {
const filePath = path.join(IMAGES_DIR, file);
const stats = fs.statSync(filePath);
if (stats.isFile()) {
totalSize += stats.size;
fileCount++;
}
});
resolve({
totalSize,
totalSizeGB: (totalSize / (1024 ** 3)).toFixed(2),
fileCount,
dataDir: IMAGES_DIR
});
} catch (err) {
reject(err);
}
});
}
/**
* Clean up orphaned files (files in storage but not in database)
*/
async function cleanupOrphanedFiles(db) {
try {
if (!fs.existsSync(IMAGES_DIR)) {
return { cleaned: 0 };
}
const files = fs.readdirSync(IMAGES_DIR);
let cleaned = 0;
for (const file of files) {
const filePath = path.join(IMAGES_DIR, file);
// Check if file exists in database
const existsInDb = await new Promise((resolve) => {
db.get(
'SELECT id FROM images WHERE filename = ?',
[file],
(err, row) => resolve(!err && !!row)
);
});
if (!existsInDb) {
try {
fs.unlinkSync(filePath);
cleaned++;
} catch (err) {
console.error(`Failed to delete orphaned file ${file}:`, err.message);
}
}
}
return { cleaned };
} catch (err) {
console.error('Cleanup error:', err);
return { cleaned: 0, error: err.message };
}
}
module.exports = {
downloadAndSaveImage,
verifyImageIntegrity,
deleteImageFile,
getImageBuffer,
getStorageStats,
calculateFileHash,
generateFilename,
getFileExtension,
cleanupOrphanedFiles,
IMAGES_DIR
};

View File

@@ -11,6 +11,9 @@
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2"
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"axios": "^1.6.0",
"crypto": "^1.0.1"
}
}

587
public/gallery.html Normal file
View File

@@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeBase Image Gallery</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.controls {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.controls label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #333;
}
.controls select,
.controls input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95em;
}
.controls select {
cursor: pointer;
}
.stats {
background: white;
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 30px;
display: flex;
gap: 30px;
flex-wrap: wrap;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.9em;
color: #666;
font-weight: 500;
}
.stat-value {
font-size: 1.8em;
font-weight: bold;
color: #667eea;
}
.timeline {
position: relative;
padding: 20px 0;
}
.timeline::before {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 3px;
height: 100%;
background: rgba(255, 255, 255, 0.3);
}
.timeline-item {
margin-bottom: 40px;
position: relative;
}
.timeline-item:nth-child(odd) .timeline-content {
margin-left: 0;
margin-right: auto;
width: calc(50% - 20px);
text-align: right;
}
.timeline-item:nth-child(even) .timeline-content {
margin-left: auto;
margin-right: 0;
width: calc(50% - 20px);
text-align: left;
}
.timeline-marker {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 16px;
height: 16px;
background: white;
border: 3px solid #667eea;
border-radius: 50%;
z-index: 10;
}
.timeline-content {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.timeline-content:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.timeline-content img {
width: 100%;
height: auto;
max-height: 300px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 12px;
}
.timeline-timestamp {
font-weight: 600;
color: #667eea;
font-size: 0.95em;
margin-bottom: 8px;
}
.timeline-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.timeline-tag {
display: inline-block;
background: #f0f0f0;
color: #555;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85em;
}
.timeline-tag.active {
background: #667eea;
color: white;
}
.timeline-size {
font-size: 0.85em;
color: #999;
}
.loading {
text-align: center;
color: white;
font-size: 1.1em;
}
.error {
background: #ff6b6b;
color: white;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
.empty {
background: white;
border-radius: 8px;
padding: 40px;
text-align: center;
color: #999;
}
@media (max-width: 768px) {
.timeline::before {
left: 20px;
}
.timeline-marker {
left: 20px;
}
.timeline-item:nth-child(odd) .timeline-content,
.timeline-item:nth-child(even) .timeline-content {
width: calc(100% - 50px);
margin-left: 50px;
margin-right: 0;
text-align: left;
}
.header h1 {
font-size: 1.8em;
}
.controls {
flex-direction: column;
}
.stats {
flex-direction: column;
gap: 15px;
}
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
position: relative;
margin: auto;
padding: 0;
max-width: 90vw;
max-height: 90vh;
top: 50%;
transform: translateY(-50%);
}
.modal-content img {
width: 100%;
height: auto;
border-radius: 8px;
}
.modal-info {
background: white;
padding: 20px;
border-radius: 0 0 8px 8px;
}
.close {
position: absolute;
right: 20px;
top: 20px;
font-size: 2em;
font-weight: bold;
color: white;
cursor: pointer;
z-index: 1001;
background: rgba(0, 0, 0, 0.5);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close:hover {
background: rgba(0, 0, 0, 0.7);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📸 Image Timeline</h1>
<p>Browse your captured images chronologically</p>
</div>
<div class="controls">
<label>
Sort by:
<select id="sortSelect">
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</select>
</label>
<label>
Filter by tag:
<select id="tagSelect">
<option value="">All Tags</option>
</select>
</label>
<label>
Refresh:
<input type="checkbox" id="autoRefresh" unchecked>
</label>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">Total Images</span>
<span class="stat-value" id="totalCount">0</span>
</div>
<div class="stat">
<span class="stat-label">Total Storage</span>
<span class="stat-value" id="totalSize">0 GB</span>
</div>
<div class="stat">
<span class="stat-label">Last Updated</span>
<span class="stat-value" id="lastUpdated">--:--</span>
</div>
</div>
<div id="loading" class="loading">Loading images...</div>
<div id="error" class="error" style="display:none;"></div>
<div class="timeline" id="timeline" style="display:none;"></div>
<div class="empty" id="empty" style="display:none;">No images found. Start fetching to see images here!</div>
</div>
<!-- Modal for full image view -->
<div id="imageModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<img id="modalImage" src="" alt="">
<div class="modal-info" id="modalInfo"></div>
</div>
</div>
<script>
const API_BASE = '';
let allImages = [];
let allTags = [];
let refreshInterval = null;
const timeline = document.getElementById('timeline');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const empty = document.getElementById('empty');
const sortSelect = document.getElementById('sortSelect');
const tagSelect = document.getElementById('tagSelect');
const autoRefresh = document.getElementById('autoRefresh');
const modal = document.getElementById('imageModal');
const closeBtn = document.querySelector('.close');
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function loadImages() {
try {
loading.style.display = 'block';
error.style.display = 'none';
timeline.style.display = 'none';
empty.style.display = 'none';
// Fetch images
const response = await fetch(`${API_BASE}/api/images?pageSize=999`);
if (!response.ok) throw new Error('Failed to fetch images');
const data = await response.json();
allImages = data.images || [];
// Fetch tags
const tagsResponse = await fetch(`${API_BASE}/api/tags`);
const tagsData = await tagsResponse.json();
allTags = tagsData.tags || [];
// Fetch stats
const statsResponse = await fetch(`${API_BASE}/api/stats`);
const statsData = statsResponse.ok ? await statsResponse.json() : null;
if (statsData) {
document.getElementById('totalCount').textContent = statsData.stats.imageCount || allImages.length;
document.getElementById('totalSize').textContent = statsData.stats.totalSizeGB + ' GB' || '0 GB';
document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
}
// Update tag filter
updateTagFilter();
// Render images
renderTimeline();
loading.style.display = 'none';
} catch (err) {
console.error('Error loading images:', err);
error.style.display = 'block';
error.textContent = 'Error loading images: ' + err.message;
loading.style.display = 'none';
}
}
function updateTagFilter() {
const currentValue = tagSelect.value;
tagSelect.innerHTML = '<option value="">All Tags</option>';
allTags.forEach(tag => {
const option = document.createElement('option');
option.value = tag;
option.textContent = tag;
tagSelect.appendChild(option);
});
tagSelect.value = currentValue;
}
function renderTimeline() {
// Filter images
const selectedTag = tagSelect.value;
let filtered = allImages;
if (selectedTag) {
filtered = allImages.filter(img =>
img.tags && img.tags.includes(selectedTag)
);
}
// Sort images
const sorted = [...filtered];
if (sortSelect.value === 'oldest') {
sorted.reverse();
}
// Clear timeline
timeline.innerHTML = '';
if (sorted.length === 0) {
empty.style.display = 'block';
timeline.style.display = 'none';
return;
}
// Add items
sorted.forEach(image => {
const item = document.createElement('div');
item.className = 'timeline-item';
const content = document.createElement('div');
content.className = 'timeline-content';
content.onclick = () => showModal(image);
const img = document.createElement('img');
img.src = `${API_BASE}/api/images/${image.id}/download`;
img.onerror = () => img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100"%3E%3Crect fill="%23ccc" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3EImage Error%3C/text%3E%3C/svg%3E';
const timestamp = document.createElement('div');
timestamp.className = 'timeline-timestamp';
timestamp.textContent = formatDate(image.fetched_at);
const tagsDiv = document.createElement('div');
tagsDiv.className = 'timeline-tags';
if (image.tags && image.tags.length > 0) {
image.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'timeline-tag';
if (selectedTag === tag) tagEl.classList.add('active');
tagEl.textContent = tag;
tagEl.onclick = (e) => {
e.stopPropagation();
tagSelect.value = tag;
renderTimeline();
};
tagsDiv.appendChild(tagEl);
});
}
const size = document.createElement('div');
size.className = 'timeline-size';
size.textContent = formatSize(image.filesize);
content.appendChild(img);
content.appendChild(timestamp);
content.appendChild(tagsDiv);
content.appendChild(size);
const marker = document.createElement('div');
marker.className = 'timeline-marker';
item.appendChild(marker);
item.appendChild(content);
timeline.appendChild(item);
});
timeline.style.display = 'block';
empty.style.display = 'none';
}
function showModal(image) {
document.getElementById('modalImage').src = `${API_BASE}/api/images/${image.id}/download`;
const info = document.getElementById('modalInfo');
info.innerHTML = `
<div style="margin-bottom: 12px;">
<strong style="color: #667eea;">${formatDate(image.fetched_at)}</strong>
</div>
<div style="margin-bottom: 8px;">
<strong>Size:</strong> ${formatSize(image.filesize)}
</div>
<div style="margin-bottom: 8px;">
<strong>Hash:</strong> <code style="background: #f5f5f5; padding: 4px 8px; border-radius: 4px; font-size: 0.85em;">${image.file_hash.substring(0, 16)}...</code>
</div>
${image.tags && image.tags.length > 0 ? `
<div>
<strong>Tags:</strong> ${image.tags.map(tag => `<span class="timeline-tag">${tag}</span>`).join(' ')}
</div>
` : ''}
`;
modal.style.display = 'block';
}
closeBtn.onclick = () => {
modal.style.display = 'none';
};
modal.onclick = (e) => {
if (e.target === modal) {
modal.style.display = 'none';
}
};
sortSelect.addEventListener('change', renderTimeline);
tagSelect.addEventListener('change', renderTimeline);
autoRefresh.addEventListener('change', (e) => {
if (e.target.checked) {
loadImages();
refreshInterval = setInterval(loadImages, 3000);
} else {
if (refreshInterval) clearInterval(refreshInterval);
}
});
// Initial load
loadImages();
</script>
</body>
</html>

View File

@@ -36,6 +36,27 @@
</div>
</div>
</section>
<section class="features-section">
<h2>Features</h2>
<div class="features-grid">
<a href="/gallery.html" class="feature-card">
<div class="feature-icon">📸</div>
<h3>Image Gallery</h3>
<p>Browse captured images on an interactive timeline</p>
</a>
<a href="/api/stats" class="feature-card">
<div class="feature-icon">📊</div>
<h3>Statistics</h3>
<p>View storage usage and image metrics</p>
</a>
<a href="/api/status" class="feature-card">
<div class="feature-icon"></div>
<h3>API Status</h3>
<p>Check system status and features</p>
</a>
</div>
</section>
</main>
<footer>

View File

@@ -133,6 +133,51 @@ section h2 {
font-weight: 500;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.feature-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem 1.5rem;
text-decoration: none;
color: inherit;
transition: all 0.3s ease;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.feature-card:hover {
background-color: var(--bg-tertiary);
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(74, 158, 255, 0.2);
}
.feature-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.feature-card h3 {
color: var(--accent);
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.feature-card p {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
footer {
text-align: center;
padding-top: 2rem;
@@ -159,6 +204,10 @@ footer {
.info-grid {
grid-template-columns: 1fr;
}
.features-grid {
grid-template-columns: 1fr;
}
}
/* Animations */

235
routes/images.js Normal file
View File

@@ -0,0 +1,235 @@
const express = require('express');
const router = express.Router();
const database = require('../lib/database');
const storage = require('../lib/storage');
const imageFetcher = require('../lib/image-fetcher');
/**
* GET /api/images - Get all images with optional filtering
*/
router.get('/images', async (req, res) => {
try {
const { tag, sourceUrl, page = 1, pageSize = 50, sort = 'fetched_at', order = 'DESC' } = req.query;
const limit = parseInt(pageSize);
const offset = (parseInt(page) - 1) * limit;
const images = await database.getImages({
tag,
sourceUrl,
limit,
offset,
sortBy: sort,
order
});
// Get tags for each image
for (let i = 0; i < images.length; i++) {
images[i].tags = await database.getImageTags(images[i].id);
}
const total = await database.getImageCount(tag);
res.json({
success: true,
images,
pagination: {
page: parseInt(page),
pageSize: limit,
total,
pages: Math.ceil(total / limit)
}
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/images/:id - Get a specific image
*/
router.get('/images/:id', async (req, res) => {
try {
const image = await database.getImage(req.params.id);
if (!image) {
return res.status(404).json({ success: false, error: 'Image not found' });
}
image.tags = await database.getImageTags(image.id);
res.json({ success: true, image });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/images/:id/download - Download image file
*/
router.get('/images/:id/download', async (req, res) => {
try {
const image = await database.getImage(req.params.id);
if (!image) {
return res.status(404).json({ success: false, error: 'Image not found' });
}
const buffer = await storage.getImageBuffer(image.file_path);
res.contentType(image.mime_type);
res.send(buffer);
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /api/images - Fetch and save a new image
*/
router.post('/images', async (req, res) => {
try {
const { source_url, tags = [] } = req.body;
if (!source_url) {
return res.status(400).json({ success: false, error: 'source_url is required' });
}
const result = await imageFetcher.fetchImage(source_url, tags);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const image = await database.getImage(result.imageId);
image.tags = await database.getImageTags(image.id);
res.status(201).json({ success: true, image });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /api/images/:id/tags - Add tags to an image
*/
router.post('/images/:id/tags', async (req, res) => {
try {
const { tags = [] } = req.body;
if (!Array.isArray(tags) || tags.length === 0) {
return res.status(400).json({ success: false, error: 'tags must be a non-empty array' });
}
const image = await database.getImage(req.params.id);
if (!image) {
return res.status(404).json({ success: false, error: 'Image not found' });
}
await database.addTags(image.id, tags);
image.tags = await database.getImageTags(image.id);
res.json({ success: true, image });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* DELETE /api/images/:id - Delete an image
*/
router.delete('/images/:id', async (req, res) => {
try {
const image = await database.getImage(req.params.id);
if (!image) {
return res.status(404).json({ success: false, error: 'Image not found' });
}
// Delete file
await storage.deleteImageFile(image.file_path);
// Delete database record
await database.deleteImage(image.id);
res.json({ success: true, message: 'Image deleted' });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/tags - Get all available tags
*/
router.get('/tags', async (req, res) => {
try {
const tags = await database.getAllTags();
res.json({ success: true, tags });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/stats - Get storage and image statistics
*/
router.get('/stats', async (req, res) => {
try {
const storageStats = await storage.getStorageStats();
const imageCount = await database.getImageCount();
res.json({
success: true,
stats: {
imageCount,
...storageStats
}
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /api/verify - Verify all images for corruption
*/
router.post('/verify', async (req, res) => {
try {
const result = await imageFetcher.verifyAllImages();
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /api/cleanup - Clean up old images
*/
router.post('/cleanup', async (req, res) => {
try {
const { daysOld = 30 } = req.body;
const deleted = await database.cleanupOldImages(daysOld);
const orphaned = await storage.cleanupOrphanedFiles(database.db);
res.json({
success: true,
cleanup: {
deletedFromDb: deleted,
orphanedFilesRemoved: orphaned.cleaned
}
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/fetcher/status - Get fetcher status
*/
router.get('/fetcher/status', (req, res) => {
const status = imageFetcher.getStatus();
res.json({ success: true, status });
});
module.exports = router;

View File

@@ -25,6 +25,8 @@ $files = @(
"docker-compose.yml",
"package.json",
"server.js",
"setup.js",
"image-sources.json",
".dockerignore",
"homebase.service"
)
@@ -38,12 +40,14 @@ foreach ($file in $files) {
}
}
# Copy public folder recursively
Write-Host " Copying public folder..." -ForegroundColor Gray
scp -r "public" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
if ($LASTEXITCODE -ne 0) {
Write-Host "[!!] Failed to copy public folder" -ForegroundColor Red
exit 1
# Copy directories recursively
foreach ($dir in @("public", "lib", "routes", "scripts")) {
Write-Host " Copying $dir folder..." -ForegroundColor Gray
scp -r $dir "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
if ($LASTEXITCODE -ne 0) {
Write-Host "[!!] Failed to copy $dir folder" -ForegroundColor Red
exit 1
}
}
# Fix permissions on the remote server

View File

@@ -1,30 +1,103 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const database = require('./lib/database');
const imageFetcher = require('./lib/image-fetcher');
const imagesRouter = require('./routes/images');
const app = express();
const PORT = process.env.PORT || 3001;
const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com';
// Serve static files from public folder
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// API endpoint
// Initialize database
database.initializeDatabase().catch(err => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
// API Routes
app.use('/api', imagesRouter);
// Status endpoint
app.get('/api/status', (req, res) => {
res.json({
message: 'HomeBase is running!',
domain: DOMAIN,
timestamp: new Date().toISOString(),
version: '1.0.0'
version: '1.0.0',
features: {
imageStorage: true,
tagging: true,
corruptionDetection: true
}
});
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
console.log(`Domain: ${DOMAIN}`);
console.log(`Visit: http://${DOMAIN}`);
// Start server
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`\n🏠 HomeBase Server Started`);
console.log(` Port: ${PORT}`);
console.log(` Domain: ${DOMAIN}`);
console.log(` URL: http://${DOMAIN}\n`);
// Load configuration from image-sources.json
const configPath = path.join(__dirname, 'image-sources.json');
let config = { sources: [], fetchInterval: 2.5 };
try {
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
config = JSON.parse(fileContent);
}
} catch (err) {
console.warn('⚠️ Could not read image-sources.json:', err.message);
}
const imageSources = config.sources || [];
const fetchInterval = config.fetchInterval || 2.5;
// Start image fetcher if sources are configured
if (imageSources.length > 0) {
const enabledSources = imageSources.filter(s => s.enabled);
if (enabledSources.length > 0) {
imageFetcher.startFetcher(enabledSources, fetchInterval);
console.log(`📸 Image fetcher: Enabled (${fetchInterval} minute interval)\n`);
} else {
console.log('📸 Image fetcher: Disabled (no enabled sources in config)');
console.log(' Edit image-sources.json and set "enabled": true to enable\n');
}
} else {
console.log('📸 Image fetcher: No sources configured');
console.log(' Edit image-sources.json to add image sources\n');
}
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('\n📴 Shutting down gracefully...');
imageFetcher.stopFetcher();
server.close(async () => {
await database.closeDatabase();
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('\n📴 Shutting down gracefully...');
imageFetcher.stopFetcher();
server.close(async () => {
await database.closeDatabase();
process.exit(0);
});
});

45
setup.bat Normal file
View File

@@ -0,0 +1,45 @@
@echo off
REM HomeBase Image Storage - Setup Script
REM Run this script to initialize the image storage system
echo.
echo ========================================
echo HomeBase Image Storage Setup
echo ========================================
echo.
REM Check if Node.js is installed
where node >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo ERROR: Node.js is not installed or not in PATH
echo Please install Node.js from https://nodejs.org
pause
exit /b 1
)
echo [1/3] Installing dependencies...
call npm install
if %ERRORLEVEL% NEQ 0 (
echo ERROR: Failed to install dependencies
pause
exit /b 1
)
echo.
echo [2/3] Initializing database...
call node setup.js init
if %ERRORLEVEL% NEQ 0 (
echo ERROR: Failed to initialize database
pause
exit /b 1
)
echo.
echo [3/3] Setup complete!
echo.
echo Next steps:
echo 1. Edit image-sources.json to add your image sources
echo 2. Set "enabled": true for sources you want to use
echo 3. Run: npm start
echo.
pause

199
setup.js Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* HomeBase Image Storage Setup and Configuration Script
* This script helps you configure image sources and test the storage system
*/
const fs = require('fs');
const path = require('path');
const database = require('./lib/database');
const storage = require('./lib/storage');
const imageFetcher = require('./lib/image-fetcher');
const axios = require('axios');
const CONFIG_FILE = path.join(__dirname, 'image-sources.json');
/**
* Create default configuration file
*/
async function createDefaultConfig() {
const defaultConfig = {
sources: [
{
name: "Example Source",
url: "https://picsum.photos/800/600?random=1",
tags: ["example", "test"],
enabled: false
}
],
fetchInterval: 0.033
};
fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2));
console.log(`✅ Created default configuration at: ${CONFIG_FILE}`);
console.log(' Edit this file to add your image sources');
console.log(' fetchInterval: 0.033 = 2 seconds. Adjust as needed (0.0167 = 1s, 2.5 = 2.5min)');
return defaultConfig;
}
/**
* Load configuration
*/
function loadConfig() {
if (!fs.existsSync(CONFIG_FILE)) {
return createDefaultConfig();
}
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
console.error('❌ Error reading configuration:', err.message);
process.exit(1);
}
}
/**
* Get enabled sources from configuration
*/
function getEnabledSources(config) {
return config.sources.filter(source => source.enabled);
}
/**
* Initialize storage directories and database
*/
async function initialize() {
console.log('\n🚀 Initializing HomeBase Image Storage System...\n');
try {
// Initialize database
console.log('📦 Setting up database...');
await database.initializeDatabase();
console.log(' ✅ Database initialized');
// Check storage directory
console.log('\n💾 Checking storage...');
const stats = await storage.getStorageStats();
console.log(` ✅ Storage directory: ${stats.dataDir}`);
console.log(` 📊 Current usage: ${stats.totalSizeGB} GB (${stats.fileCount} files)`);
// Load configuration
console.log('\n⚙ Loading configuration...');
const config = loadConfig();
const enabledSources = getEnabledSources(config);
console.log(` ✅ Configuration loaded`);
console.log(` 📍 Image sources configured: ${config.sources.length}`);
console.log(` ✨ Enabled sources: ${enabledSources.length}`);
// Test image fetch if enabled sources exist
if (enabledSources.length > 0) {
console.log('\n🧪 Testing image fetch...');
const testSource = enabledSources[0];
console.log(` Fetching from: ${testSource.url}`);
const result = await imageFetcher.fetchImage(testSource.url, testSource.tags);
if (result.success) {
console.log(` ✅ Successfully fetched and stored image`);
console.log(` 📄 Filename: ${result.filename}`);
console.log(` 💾 Size: ${(result.filesize / 1024).toFixed(2)} KB`);
} else {
console.log(` ⚠️ Failed to fetch: ${result.error}`);
}
} else {
console.log('\n⚠ No enabled image sources found');
console.log(' Edit image-sources.json and set "enabled": true to enable sources');
}
console.log('\n✨ Initialization complete!\n');
console.log('📖 API Endpoints:');
console.log(' GET /api/images - List all images');
console.log(' POST /api/images - Fetch and save new image');
console.log(' GET /api/images/{id} - Get image details');
console.log(' POST /api/images/{id}/tags - Add tags to image');
console.log(' GET /api/tags - List all tags');
console.log(' GET /api/stats - Storage statistics');
console.log(' POST /api/verify - Verify image integrity');
console.log(' POST /api/cleanup - Clean up old images');
console.log('\n');
} catch (err) {
console.error('❌ Initialization failed:', err.message);
process.exit(1);
} finally {
await database.closeDatabase();
}
}
/**
* Test image fetch
*/
async function testFetch(url, tags = []) {
console.log('\n🧪 Testing Image Fetch\n');
try {
await database.initializeDatabase();
console.log(`Fetching from: ${url}`);
console.log(`Tags: ${tags.join(', ') || 'none'}\n`);
const result = await imageFetcher.fetchImage(url, tags);
if (result.success) {
console.log('✅ Success!');
console.log(` Image ID: ${result.imageId}`);
console.log(` Filename: ${result.filename}`);
console.log(` Size: ${(result.filesize / 1024).toFixed(2)} KB`);
const image = await database.getImage(result.imageId);
console.log(` Hash: ${image.file_hash}`);
} else {
console.log(`❌ Failed: ${result.error}`);
}
} catch (err) {
console.error('❌ Error:', err.message);
} finally {
await database.closeDatabase();
}
}
/**
* Show usage information
*/
function showUsage() {
console.log('\nHomeBase Image Storage CLI\n');
console.log('Usage:');
console.log(' node setup.js init - Initialize system');
console.log(' node setup.js test <url> [tags] - Test fetch from URL');
console.log(' node setup.js config - Show configuration');
console.log(' node setup.js help - Show this help\n');
}
// Main
const command = process.argv[2];
switch (command) {
case 'init':
initialize();
break;
case 'test':
const url = process.argv[3];
const tags = process.argv.slice(4).join(' ').split(',').map(t => t.trim()).filter(t => t);
if (!url) {
console.error('❌ URL required: node setup.js test <url> [tags]');
process.exit(1);
}
testFetch(url, tags);
break;
case 'config':
const config = loadConfig();
console.log('\nCurrent Configuration:\n');
console.log(JSON.stringify(config, null, 2));
break;
case 'help':
default:
showUsage();
break;
}