diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db5a59e --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6bb4081 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2d06071 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..225e495 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', }; export default nextConfig; diff --git a/src/app/api/bookmarks/[id]/route.ts b/src/app/api/bookmarks/[id]/route.ts index e6dfc1f..9aa2c08 100644 --- a/src/app/api/bookmarks/[id]/route.ts +++ b/src/app/api/bookmarks/[id]/route.ts @@ -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 = ? diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts index ae26e3c..70f2d2d 100644 --- a/src/app/api/bookmarks/route.ts +++ b/src/app/api/bookmarks/route.ts @@ -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) { diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts index ffcfea0..f1d8fd9 100644 --- a/src/app/api/files/route.ts +++ b/src/app/api/files/route.ts @@ -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 diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..59c554f --- /dev/null +++ b/src/app/api/health/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/libraries/[id]/route.ts b/src/app/api/libraries/[id]/route.ts index 0aa43ff..a232b50 100644 --- a/src/app/api/libraries/[id]/route.ts +++ b/src/app/api/libraries/[id]/route.ts @@ -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 }); diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index 5632937..5961a14 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -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) { diff --git a/src/app/api/photos/[id]/route.ts b/src/app/api/photos/[id]/route.ts index 8c44136..bfc4cc6 100644 --- a/src/app/api/photos/[id]/route.ts +++ b/src/app/api/photos/[id]/route.ts @@ -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); diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts index f3a537f..cf31d9f 100644 --- a/src/app/api/photos/route.ts +++ b/src/app/api/photos/route.ts @@ -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 diff --git a/src/app/api/stars/[id]/route.ts b/src/app/api/stars/[id]/route.ts index b20218d..65035bf 100644 --- a/src/app/api/stars/[id]/route.ts +++ b/src/app/api/stars/[id]/route.ts @@ -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); diff --git a/src/app/api/stars/route.ts b/src/app/api/stars/route.ts index c1e9148..734b262 100644 --- a/src/app/api/stars/route.ts +++ b/src/app/api/stars/route.ts @@ -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) { diff --git a/src/app/api/stream/[id]/route.ts b/src/app/api/stream/[id]/route.ts index 7174e7d..8ef57c9 100644 --- a/src/app/api/stream/[id]/route.ts +++ b/src/app/api/stream/[id]/route.ts @@ -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); diff --git a/src/app/api/videos/[id]/route.ts b/src/app/api/videos/[id]/route.ts index 13fc420..2b00aed 100644 --- a/src/app/api/videos/[id]/route.ts +++ b/src/app/api/videos/[id]/route.ts @@ -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); diff --git a/src/app/api/videos/route.ts b/src/app/api/videos/route.ts index f0879cc..8cec6df 100644 --- a/src/app/api/videos/route.ts +++ b/src/app/api/videos/route.ts @@ -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'); diff --git a/src/db/index.ts b/src/db/index.ts index 8779002..9f76068 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,68 +1,82 @@ -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(` - CREATE TABLE IF NOT EXISTS libraries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE - ); -`); +function initializeDatabase() { + if (db) return db; + + const dbPath = path.join(process.cwd(), 'media.db'); + db = new Database(dbPath); -db.exec(` - CREATE TABLE IF NOT EXISTS media ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - library_id INTEGER, - path TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - title TEXT, - size INTEGER, - thumbnail TEXT, - bookmark_count INTEGER DEFAULT 0, - star_count INTEGER DEFAULT 0, - avg_rating REAL DEFAULT 0.0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (library_id) REFERENCES libraries (id) - ); -`); + // Create tables + db.exec(` + CREATE TABLE IF NOT EXISTS libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE + ); + `); -db.exec(` - CREATE TABLE IF NOT EXISTS bookmarks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - media_id INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE - ); -`); + db.exec(` + CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER, + path TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + title TEXT, + size INTEGER, + thumbnail TEXT, + bookmark_count INTEGER DEFAULT 0, + star_count INTEGER DEFAULT 0, + avg_rating REAL DEFAULT 0.0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (library_id) REFERENCES libraries (id) + ); + `); -db.exec(` - CREATE TABLE IF NOT EXISTS stars ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - media_id INTEGER NOT NULL, - rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE - ); -`); + db.exec(` + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + 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);`); + db.exec(` + CREATE TABLE IF NOT EXISTS stars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id INTEGER NOT NULL, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE + ); + `); -// 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);`); + // 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);`); -// 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);`); + // 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);`); -export default db; + // 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);`); + + return db; +} + +export function getDatabase(): DatabaseType { + return initializeDatabase(); +} + +// For backward compatibility, export the database instance getter +export default getDatabase; diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index cd12e55..7caca47 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -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`);