feat: add Docker configuration and database initialization

- Introduced a .dockerignore file to exclude unnecessary files from Docker builds.
- Created a Dockerfile for building the Next.js application with optimized production settings.
- Added a docker-compose.yml file for orchestrating services, including NextAV, FFmpeg, and Nginx.
- Refactored database access to use a singleton pattern for better management and initialization of the SQLite database.
- Updated API routes to utilize the new database access method, enhancing consistency across the application.
This commit is contained in:
tigeren 2025-08-30 17:42:26 +00:00
parent 158f9f7a23
commit 0c1119be46
19 changed files with 337 additions and 71 deletions

66
.dockerignore Normal file
View File

@ -0,0 +1,66 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Next.js build output
.next
out
# Production build
dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Documentation
README.md
*.md
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# Temporary folders
tmp
temp

74
Dockerfile Normal file
View File

@ -0,0 +1,74 @@
# Use official Node.js runtime as the base image
FROM node:22.18.0 AS base
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Install build dependencies for native modules
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install pnpm globally
RUN npm install -g pnpm
# Copy package files and install all dependencies (including dev dependencies)
COPY package.json package-lock.json ./
RUN pnpm install
# Copy source code
COPY . .
# Rebuild better-sqlite3 to ensure native bindings are compiled correctly
RUN pnpm rebuild better-sqlite3
# Ensure database file exists and has proper permissions
RUN touch /app/media.db && chmod 666 /app/media.db
# Create directories for media storage
RUN mkdir -p /app/data /app/media
# Build the application
RUN pnpm build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install FFmpeg and FFprobe for thumbnail generation
RUN apt-get update && apt-get install -y \
ffmpeg \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 --gid nodejs nextjs
# Create media directories
RUN mkdir -p /app/data /app/media
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/media.db ./media.db
# Set up volume for persistent data
VOLUME ["/app/data", "/app/media"]
# Switch to non-root user
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

57
docker-compose.yml Normal file
View File

@ -0,0 +1,57 @@
version: '3.8'
services:
nextav:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./media:/app/media
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/nextav.db
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
depends_on:
- ffmpeg
# FFmpeg service for thumbnail generation (optional - can use host FFmpeg)
ffmpeg:
image: jrottenberg/ffmpeg:4.4-alpine
volumes:
- ./media:/media:ro
command: tail -f /dev/null # Keep container running
restart: unless-stopped
# Nginx reverse proxy (optional for production)
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- nextav
restart: unless-stopped
profiles:
- production
volumes:
nextav_data:
driver: local
nextav_media:
driver: local
networks:
default:
name: nextav-network

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
};
export default nextConfig;

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@ -10,6 +10,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Invalid media ID' }, { status: 400 });
}
const db = getDatabase();
// Check if media exists
const media = db.prepare(`
SELECT id FROM media WHERE id = ?
@ -55,6 +56,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Invalid media ID' }, { status: 400 });
}
const db = getDatabase();
// Check if bookmark exists
const bookmark = db.prepare(`
SELECT id FROM bookmarks WHERE media_id = ?

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@ -26,6 +26,7 @@ export async function GET(request: Request) {
}
try {
const db = getDatabase();
// Get total count for pagination
const countQuery = `
SELECT COUNT(*) as total
@ -63,6 +64,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const db = getDatabase();
const { mediaId } = await request.json();
if (!mediaId) {

View File

@ -2,7 +2,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import db from '@/db';
import { getDatabase } from '@/db';
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
@ -16,6 +16,7 @@ export async function GET(request: Request) {
}
try {
const db = getDatabase();
const files = fs.readdirSync(dirPath);
// Get media files from database for this path

View File

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { existsSync } from 'fs';
import path from 'path';
import { getDatabase } from '@/db';
export async function GET() {
try {
// Check if database is accessible by actually connecting to it
try {
const db = getDatabase();
// Test a simple query
db.prepare('SELECT 1').get();
} catch (dbError) {
return NextResponse.json(
{ status: 'unhealthy', error: `Database not accessible: ${(dbError as Error).message}` },
{ status: 503 }
);
}
// Check if media directory is accessible
const mediaRoot = process.env.NEXT_PUBLIC_MEDIA_ROOT || '/app/media';
if (!existsSync(mediaRoot)) {
return NextResponse.json(
{ status: 'unhealthy', error: 'Media directory not accessible' },
{ status: 503 }
);
}
return NextResponse.json({ status: 'healthy' });
} catch (error) {
return NextResponse.json(
{ status: 'unhealthy', error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function DELETE(request: NextRequest, { params: paramsPromise }: { params: Promise<{ id: string }> }) {
const params = await paramsPromise;
const db = getDatabase();
const id = parseInt(params.id, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });

View File

@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function GET() {
const db = getDatabase();
const libraries = db.prepare('SELECT * FROM libraries').all();
return NextResponse.json(libraries);
}
@ -14,6 +15,7 @@ export async function POST(request: Request) {
}
try {
const db = getDatabase();
const info = db.prepare('INSERT INTO libraries (path) VALUES (?)').run(path);
return NextResponse.json({ id: info.lastInsertRowid, path });
} catch (error: any) {

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import db from "@/db";
import { getDatabase } from "@/db";
import fs from "fs";
import path from "path";
@ -8,6 +8,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
try {
const photoId = parseInt(id);

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@ -25,6 +25,7 @@ export async function GET(request: Request) {
params.push(`%${search}%`, `%${search}%`);
}
const db = getDatabase();
// Get total count for pagination
const countQuery = `
SELECT COUNT(*) as total

View File

@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const db = getDatabase();
try {
const parsedId = parseInt(id);

View File

@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function GET(request: Request) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
const mediaId = searchParams.get('mediaId');
@ -33,6 +34,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const db = getDatabase();
const { mediaId, rating } = await request.json();
if (!mediaId || !rating) {

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import db from "@/db";
import { getDatabase } from "@/db";
import fs from "fs";
import path from "path";
@ -8,6 +8,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
try {
const videoId = parseInt(id);

View File

@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import db from '@/db';
import { getDatabase } from '@/db';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const db = getDatabase();
try {
const parsedId = parseInt(id);

View File

@ -1,9 +1,10 @@
import { NextResponse } from "next/server";
import db from "@/db";
import { getDatabase } from "@/db";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const db = getDatabase();
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
const offset = parseInt(searchParams.get('offset') || '0');

View File

@ -1,18 +1,24 @@
import Database from 'better-sqlite3';
import Database, { Database as DatabaseType } from 'better-sqlite3';
import path from 'path';
const dbPath = path.join(process.cwd(), 'media.db');
const db = new Database(dbPath);
let db: DatabaseType | null = null;
db.exec(`
function initializeDatabase() {
if (db) return db;
const dbPath = path.join(process.cwd(), 'media.db');
db = new Database(dbPath);
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE
);
`);
`);
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER,
@ -27,9 +33,9 @@ db.exec(`
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (library_id) REFERENCES libraries (id)
);
`);
`);
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_id INTEGER NOT NULL,
@ -37,9 +43,9 @@ db.exec(`
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
);
`);
`);
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS stars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_id INTEGER NOT NULL,
@ -48,21 +54,29 @@ db.exec(`
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
);
`);
`);
// Create indexes for performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_bookmark_count ON media(bookmark_count);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_star_count ON media(star_count);`);
// Create indexes for performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_bookmark_count ON media(bookmark_count);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_star_count ON media(star_count);`);
// Pagination and filtering indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_created_at ON media(type, created_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_library_id ON media(library_id);`);
// Pagination and filtering indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_created_at ON media(type, created_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_library_id ON media(library_id);`);
// Full-text search indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_title ON media(title);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_path ON media(type, path);`);
// Full-text search indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_title ON media(title);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_path ON media(type, path);`);
export default db;
return db;
}
export function getDatabase(): DatabaseType {
return initializeDatabase();
}
// For backward compatibility, export the database instance getter
export default getDatabase;

View File

@ -1,4 +1,4 @@
import db from "@/db";
import { getDatabase } from "@/db";
import { glob } from "glob";
import path from "path";
import fs from "fs";
@ -34,6 +34,7 @@ const generatePhotoThumbnail = (photoPath: string, thumbnailPath: string) => {
};
const scanLibrary = async (library: { id: number; path: string }) => {
const db = getDatabase();
// Scan videos - handle all case variations
const videoFiles = await glob(`${library.path}/**/*.*`, { nodir: true });
@ -118,6 +119,7 @@ const scanLibrary = async (library: { id: number; path: string }) => {
};
export const scanAllLibraries = async () => {
const db = getDatabase();
const libraries = db.prepare("SELECT * FROM libraries").all() as { id: number; path: string }[];
for (const library of libraries) {
await scanLibrary(library);
@ -125,6 +127,7 @@ export const scanAllLibraries = async () => {
};
export const scanSelectedLibrary = async (libraryId: number) => {
const db = getDatabase();
const library = db.prepare("SELECT * FROM libraries WHERE id = ?").get(libraryId) as { id: number; path: string } | undefined;
if (!library) {
throw new Error(`Library with ID ${libraryId} not found`);