From c7879748f995b30ec7a7d993cf55140db78ea961 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 11 Apr 2026 11:28:04 +0800 Subject: [PATCH] =?UTF-8?q?sqlite=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 10 ++- README.md | 22 ++++- pnpm-workspace.yaml | 9 ++ scripts/init-sqlite.js | 190 +++++++++++++++++++++++++++++++++-------- server.js | 21 +++++ src/lib/db.ts | 7 +- 6 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/Dockerfile b/Dockerfile index 2c646d9..4b04780 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,11 +45,14 @@ ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 ENV DOCKER_ENV=true +ENV SQLITE_DB_PATH=/app/.data/moontv.db # 从构建器中复制 standalone 输出 COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ # 从构建器中复制 scripts 目录 COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts +# 从构建器中复制 migrations 目录 +COPY --from=builder --chown=nextjs:nodejs /app/migrations ./migrations # 从构建器中复制 start.js COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js # 从构建器中复制自定义 server.js(包含 Socket.IO 支持) @@ -58,13 +61,16 @@ COPY --from=builder --chown=nextjs:nodejs /app/server.js ./server.js COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# 从构建器中复制生产依赖(包含 Socket.IO) +# 从构建器中复制生产依赖(包含 Socket.IO / better-sqlite3) COPY --from=builder --chown=nextjs:nodejs /tmp/prod-deps/node_modules ./node_modules +# 准备 SQLite 数据目录 +RUN mkdir -p /app/.data && chown -R nextjs:nodejs /app/.data + # 切换到非特权用户 USER nextjs EXPOSE 3000 # 使用自定义启动脚本,先预加载配置再启动服务器 -CMD ["node", "start.js"] +CMD ["node", "start.js"] diff --git a/README.md b/README.md index d51b088..5e7da18 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,26 @@ volumes: kvrocks-data: ``` +### SQLite 存储 + +```yml +services: + moontv-core: + image: ghcr.io/mtvpls/moontvplus:latest + container_name: moontv-core + restart: on-failure + ports: + - '3000:3000' + environment: + - USERNAME=admin + - PASSWORD=admin_password + - NEXT_PUBLIC_STORAGE_TYPE=d1 + - SQLITE_DB_PATH=/app/.data/moontv.db + volumes: + - ./data:/app/.data +``` + + ### Redis 存储(有一定的丢数据风险) ```yml @@ -289,7 +309,7 @@ services: #### Lite 镜像说明 -`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务。 +`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务,也不支持 SQLite(`NEXT_PUBLIC_STORAGE_TYPE=d1`)自动初始化方案。 示例: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..092e9af --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +ignoredBuiltDependencies: + - bufferutil + - esbuild + - sharp + - unrs-resolver + - workerd + +onlyBuiltDependencies: + - better-sqlite3 diff --git a/scripts/init-sqlite.js b/scripts/init-sqlite.js index 2d1d5ac..91d49e0 100644 --- a/scripts/init-sqlite.js +++ b/scripts/init-sqlite.js @@ -3,60 +3,176 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const MIGRATIONS_DIR = path.join(__dirname, '../migrations'); + // SHA-256 加密密码(与 Redis 保持一致) function hashPassword(password) { return crypto.createHash('sha256').update(password).digest('hex'); } -// 确保 .data 目录存在 -const dataDir = path.join(__dirname, '../.data'); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); +function getSqliteDbPath() { + return process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db'); } -// 创建数据库 -const dbPath = path.join(dataDir, 'moontv.db'); -const db = new Database(dbPath); - -console.log('📦 Initializing SQLite database for development...'); -console.log('📍 Database location:', dbPath); - -// 读取迁移脚本 -const migrationPath = path.join(__dirname, '../migrations/001_initial_schema.sql'); -if (!fs.existsSync(migrationPath)) { - console.error('❌ Migration file not found:', migrationPath); - process.exit(1); +function ensureDataDir(dbPath) { + const dataDir = path.dirname(dbPath); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } } -const sql = fs.readFileSync(migrationPath, 'utf8'); +function configureDatabase(db) { + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('busy_timeout = 5000'); +} -// 执行迁移 -try { - db.exec(sql); - console.log('✅ Database schema created successfully!'); +function ensureMigrationTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL UNIQUE, + applied_at INTEGER NOT NULL + ) + `); +} - // 创建默认管理员用户(可选) +function getMigrationFiles() { + if (!fs.existsSync(MIGRATIONS_DIR)) { + throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`); + } + + return fs + .readdirSync(MIGRATIONS_DIR) + .filter((file) => file.endsWith('.sql')) + .sort((a, b) => a.localeCompare(b)); +} + +function columnExists(db, tableName, columnName) { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all(); + return columns.some((column) => column.name === columnName); +} + +function migrationAlreadySatisfied(db, file) { + if (file === '003_add_new_episodes_to_play_records.sql') { + return columnExists(db, 'play_records', 'new_episodes'); + } + + if (file === '004_add_tvbox_subscribe_token.sql') { + return columnExists(db, 'users', 'tvbox_subscribe_token'); + } + + return false; +} + +function markMigrationApplied(db, file) { + db.prepare( + 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)' + ).run(file, Date.now()); +} + +function runMigrations(db) { + ensureMigrationTable(db); + + const applied = new Set( + db.prepare('SELECT filename FROM schema_migrations ORDER BY filename ASC') + .all() + .map((row) => row.filename) + ); + + const migrationFiles = getMigrationFiles(); + + for (const file of migrationFiles) { + if (applied.has(file)) { + continue; + } + + if (migrationAlreadySatisfied(db, file)) { + console.log(`⏭️ Migration already satisfied, marking as applied: ${file}`); + markMigrationApplied(db, file); + continue; + } + + const migrationPath = path.join(MIGRATIONS_DIR, file); + const sql = fs.readFileSync(migrationPath, 'utf8'); + + console.log(`▶️ Applying migration: ${file}`); + + const transaction = db.transaction(() => { + db.exec(sql); + markMigrationApplied(db, file); + }); + + transaction(); + console.log(`✅ Migration applied: ${file}`); + } +} + +function ensureDefaultAdmin(db) { const username = process.env.USERNAME || 'admin'; const password = process.env.PASSWORD || '123456789'; const passwordHash = hashPassword(password); - const stmt = db.prepare(` - INSERT OR IGNORE INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated) + const existingUser = db + .prepare('SELECT username FROM users WHERE username = ? LIMIT 1') + .get(username); + + if (existingUser) { + console.log(`ℹ️ Admin user already exists: ${username}`); + return; + } + + db.prepare(` + INSERT INTO users ( + username, password_hash, role, created_at, + playrecord_migrated, favorite_migrated, skip_migrated + ) VALUES (?, ?, 'owner', ?, 1, 1, 1) - `); + `).run(username, passwordHash, Date.now()); - stmt.run(username, passwordHash, Date.now()); console.log(`✅ Default admin user created: ${username}`); -} catch (err) { - console.error('❌ Migration failed:', err); - process.exit(1); -} finally { - db.close(); } -console.log(''); -console.log('🎉 SQLite database initialized successfully!'); -console.log(''); -console.log('Next steps:'); -console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=d1 in .env'); -console.log('2. Run: npm run dev'); +function initSQLiteDatabase() { + const dbPath = getSqliteDbPath(); + ensureDataDir(dbPath); + + let db; + try { + db = new Database(dbPath); + } catch (error) { + if (error && typeof error.message === 'string' && error.message.includes('Could not locate the bindings file')) { + console.error('❌ better-sqlite3 native binding is missing or incompatible with current Node.js runtime.'); + console.error('💡 Please run: pnpm rebuild better-sqlite3'); + console.error('💡 If you recently changed Node.js version, reinstall dependencies or rebuild native modules.'); + } + throw error; + } + configureDatabase(db); + + console.log('📦 Initializing SQLite database...'); + console.log('📍 Database location:', dbPath); + + try { + runMigrations(db); + ensureDefaultAdmin(db); + } finally { + db.close(); + } + + console.log('🎉 SQLite database is ready!'); +} + +module.exports = { + initSQLiteDatabase, + getSqliteDbPath, +}; + +if (require.main === module) { + try { + initSQLiteDatabase(); + } catch (err) { + console.error('❌ SQLite initialization failed:', err); + process.exit(1); + } +} diff --git a/server.js b/server.js index 2cbf79a..6b5fc5b 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,27 @@ const { parse } = require('url'); const next = require('next'); const { Server } = require('socket.io'); +function shouldInitSQLite() { + const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; + return process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' && !isCloudflare && process.env.MOONTV_LITE !== 'true'; +} + +function ensureSQLiteReady() { + if (!shouldInitSQLite()) { + return; + } + + try { + const { initSQLiteDatabase } = require('./scripts/init-sqlite.js'); + initSQLiteDatabase(); + } catch (error) { + console.error('❌ Error initializing SQLite database:', error); + throw error; + } +} + +ensureSQLiteReady(); + const dev = process.env.NODE_ENV !== 'production'; const hostname = process.env.HOSTNAME || '0.0.0.0'; const port = parseInt(process.env.PORT || '3000', 10); diff --git a/src/lib/db.ts b/src/lib/db.ts index 5af0f91..340bcc3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -110,12 +110,15 @@ function getD1Adapter(): any { const Database = require('better-sqlite3'); const path = require('path'); - const dbPath = path.join(process.cwd(), '.data', 'moontv.db'); + const dbPath = + process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db'); const db = new Database(dbPath); db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能 + db.pragma('foreign_keys = ON'); // 与 D1 保持一致,启用外键约束 + db.pragma('busy_timeout = 5000'); // 避免启动阶段或并发写入时立即锁失败 - console.log('Using SQLite database (development mode)'); + console.log('Using SQLite database (non-Cloudflare mode)'); console.log('Database location:', dbPath); return new SQLiteAdapter(db);