Merge branch 'dev' of https://github.com/mtvpls/moontvplus-dev into dev
This commit is contained in:
+7
-1
@@ -45,11 +45,14 @@ ENV NODE_ENV=production
|
|||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV DOCKER_ENV=true
|
ENV DOCKER_ENV=true
|
||||||
|
ENV SQLITE_DB_PATH=/app/.data/moontv.db
|
||||||
|
|
||||||
# 从构建器中复制 standalone 输出
|
# 从构建器中复制 standalone 输出
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
# 从构建器中复制 scripts 目录
|
# 从构建器中复制 scripts 目录
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
|
# 从构建器中复制 migrations 目录
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/migrations ./migrations
|
||||||
# 从构建器中复制 start.js
|
# 从构建器中复制 start.js
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
||||||
# 从构建器中复制自定义 server.js(包含 Socket.IO 支持)
|
# 从构建器中复制自定义 server.js(包含 Socket.IO 支持)
|
||||||
@@ -58,9 +61,12 @@ 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/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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
|
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
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,26 @@ volumes:
|
|||||||
kvrocks-data:
|
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 存储(有一定的丢数据风险)
|
### Redis 存储(有一定的丢数据风险)
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
@@ -289,7 +309,7 @@ services:
|
|||||||
|
|
||||||
#### Lite 镜像说明
|
#### Lite 镜像说明
|
||||||
|
|
||||||
`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务。
|
`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务,也不支持 SQLite(`NEXT_PUBLIC_STORAGE_TYPE=d1`)自动初始化方案。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
|
- workerd
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
+114
-38
@@ -3,60 +3,136 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// SHA-256 加密密码(与 Redis 保持一致)
|
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
|
||||||
|
|
||||||
function hashPassword(password) {
|
function hashPassword(password) {
|
||||||
return crypto.createHash('sha256').update(password).digest('hex');
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 .data 目录存在
|
function getSqliteDbPath() {
|
||||||
const dataDir = path.join(__dirname, '../.data');
|
return process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db');
|
||||||
if (!fs.existsSync(dataDir)) {
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建数据库
|
function ensureDataDir(dbPath) {
|
||||||
const dbPath = path.join(dataDir, 'moontv.db');
|
const dataDir = path.dirname(dbPath);
|
||||||
const db = new Database(dbPath);
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
function configureDatabase(db) {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.pragma('busy_timeout = 5000');
|
||||||
|
}
|
||||||
|
|
||||||
// 执行迁移
|
function getMigrationFiles() {
|
||||||
try {
|
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||||
db.exec(sql);
|
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
||||||
console.log('✅ Database schema created successfully!');
|
}
|
||||||
|
|
||||||
// 创建默认管理员用户(可选)
|
return fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((file) => file.endsWith('.sql'))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIgnorableMigrationError(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '');
|
||||||
|
return (
|
||||||
|
message.includes('table') && message.includes('already exists') ||
|
||||||
|
message.includes('index') && message.includes('already exists') ||
|
||||||
|
message.includes('duplicate column name')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations(db) {
|
||||||
|
const migrationFiles = getMigrationFiles();
|
||||||
|
|
||||||
|
for (const file of migrationFiles) {
|
||||||
|
const migrationPath = path.join(MIGRATIONS_DIR, file);
|
||||||
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||||
|
|
||||||
|
console.log(`▶️ Applying migration: ${file}`);
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
console.log(`✅ Migration applied: ${file}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isIgnorableMigrationError(error)) {
|
||||||
|
console.log(`⏭️ Migration skipped: ${file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDefaultAdmin(db) {
|
||||||
const username = process.env.USERNAME || 'admin';
|
const username = process.env.USERNAME || 'admin';
|
||||||
const password = process.env.PASSWORD || '123456789';
|
const password = process.env.PASSWORD || '123456789';
|
||||||
const passwordHash = hashPassword(password);
|
const passwordHash = hashPassword(password);
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const existingUser = db
|
||||||
INSERT OR IGNORE INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated)
|
.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)
|
VALUES (?, ?, 'owner', ?, 1, 1, 1)
|
||||||
`);
|
`).run(username, passwordHash, Date.now());
|
||||||
|
|
||||||
stmt.run(username, passwordHash, Date.now());
|
|
||||||
console.log(`✅ Default admin user created: ${username}`);
|
console.log(`✅ Default admin user created: ${username}`);
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Migration failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
function initSQLiteDatabase() {
|
||||||
console.log('🎉 SQLite database initialized successfully!');
|
const dbPath = getSqliteDbPath();
|
||||||
console.log('');
|
ensureDataDir(dbPath);
|
||||||
console.log('Next steps:');
|
|
||||||
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=d1 in .env');
|
let db;
|
||||||
console.log('2. Run: npm run dev');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ const { parse } = require('url');
|
|||||||
const next = require('next');
|
const next = require('next');
|
||||||
const { Server } = require('socket.io');
|
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 dev = process.env.NODE_ENV !== 'production';
|
||||||
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
||||||
const port = parseInt(process.env.PORT || '3000', 10);
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
|||||||
+6
-1
@@ -173,8 +173,13 @@ export default async function RootLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
|
const runtimeStorageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||||
|
const displayStorageType = runtimeStorageType === 'd1' && !isCloudflare ? 'sqlite' : runtimeStorageType;
|
||||||
|
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
STORAGE_TYPE: runtimeStorageType,
|
||||||
|
DISPLAY_STORAGE_TYPE: displayStorageType,
|
||||||
DOUBAN_PROXY_TYPE: doubanProxyType,
|
DOUBAN_PROXY_TYPE: doubanProxyType,
|
||||||
DOUBAN_PROXY: doubanProxy,
|
DOUBAN_PROXY: doubanProxy,
|
||||||
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
|
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false);
|
const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false);
|
||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
|
const [displayStorageType, setDisplayStorageType] = useState<string>('localstorage');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
@@ -413,9 +414,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
const auth = getAuthInfoFromBrowserCookie();
|
const auth = getAuthInfoFromBrowserCookie();
|
||||||
setAuthInfo(auth);
|
setAuthInfo(auth);
|
||||||
|
|
||||||
const type =
|
const runtimeConfig = (window as any).RUNTIME_CONFIG || {};
|
||||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
const type = runtimeConfig.STORAGE_TYPE || 'localstorage';
|
||||||
|
const displayType = runtimeConfig.DISPLAY_STORAGE_TYPE || type;
|
||||||
setStorageType(type);
|
setStorageType(type);
|
||||||
|
setDisplayStorageType(displayType);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1498,7 +1501,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
|
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
|
||||||
数据存储:
|
数据存储:
|
||||||
{storageType === 'localstorage' ? '本地' : storageType}
|
{displayStorageType === 'localstorage' ? '本地' : displayStorageType}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-2
@@ -110,12 +110,15 @@ function getD1Adapter(): any {
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
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);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能
|
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);
|
console.log('Database location:', dbPath);
|
||||||
|
|
||||||
return new SQLiteAdapter(db);
|
return new SQLiteAdapter(db);
|
||||||
|
|||||||
Reference in New Issue
Block a user