feat(web): 创建 Docker Web 服务端
- 添加 Express 服务端,提供频道 API - 添加 M3U8/TS 流代理,解决跨域问题 - 添加 Dockerfile 和 docker-compose.yml - 添加 Nginx 反向代理配置 - 支持多阶段构建,自动打包前端
This commit is contained in:
parent
2a565fb8da
commit
52fc8099ae
7
web/.dockerignore
Normal file
7
web/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
32
web/Dockerfile
Normal file
32
web/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
||||
# 多阶段构建
|
||||
|
||||
# 阶段1:构建前端
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制前端代码
|
||||
COPY ../ui ./ui
|
||||
WORKDIR /app/ui
|
||||
RUN npm install && npm run build
|
||||
|
||||
# 阶段2:运行服务端
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# 复制服务端代码
|
||||
COPY src ./src
|
||||
|
||||
# 复制构建好的前端
|
||||
COPY --from=builder /app/ui/dist-web ./public
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动
|
||||
CMD ["node", "src/index.js"]
|
||||
84
web/README.md
Normal file
84
web/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# IPTV Web Docker 版
|
||||
|
||||
基于 Node.js + Express 的 IPTV Web 服务端,提供:
|
||||
|
||||
- 📺 频道列表 API
|
||||
- 🔀 M3U8/HLS 流代理(解决跨域)
|
||||
- 📦 Docker 一键部署
|
||||
- 🚀 可选 Nginx 反向代理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 构建并启动
|
||||
|
||||
```bash
|
||||
# 基础版本
|
||||
docker-compose up -d
|
||||
|
||||
# 带 Nginx 的版本
|
||||
docker-compose --profile nginx up -d
|
||||
```
|
||||
|
||||
### 2. 访问
|
||||
|
||||
```
|
||||
http://localhost:3000 # 直接访问 Node 服务
|
||||
http://localhost # 通过 Nginx(如果启用)
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/channels` | 获取频道列表 |
|
||||
| `GET /proxy/m3u8?url=xxx` | 代理 M3U8 播放列表 |
|
||||
| `GET /proxy/ts?url=xxx` | 代理 TS 视频片段 |
|
||||
| `GET /proxy/stream?url=xxx` | 通用流代理 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ └── index.js # Express 服务端
|
||||
├── nginx/
|
||||
│ └── nginx.conf # Nginx 配置
|
||||
├── public/ # 前端静态文件(构建时复制)
|
||||
├── Dockerfile # Docker 构建
|
||||
├── docker-compose.yml # 编排配置
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 更新频道数据
|
||||
|
||||
频道数据来自 `ui/dist-web/api/result.txt`,更新方式:
|
||||
|
||||
```bash
|
||||
# 方法1:重新构建
|
||||
docker-compose up -d --build
|
||||
|
||||
# 方法2:挂载数据卷(已配置)
|
||||
# 修改 ui/dist-web/api/result.txt 后自动生效
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PORT` | 3000 | 服务端口号 |
|
||||
| `NODE_ENV` | production | 运行环境 |
|
||||
|
||||
## 单独运行(不依赖 Docker)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **跨域问题** - 所有流媒体通过 `/proxy/*` 接口代理,解决浏览器 CORS 限制
|
||||
2. **性能优化** - 静态资源使用 Nginx 缓存,API 请求直连 Node
|
||||
3. **HTTPS** - 生产环境建议启用 HTTPS(配置 `nginx/ssl/` 目录)
|
||||
36
web/docker-compose.yml
Normal file
36
web/docker-compose.yml
Normal file
@ -0,0 +1,36 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
iptv-web:
|
||||
build: .
|
||||
container_name: iptv-web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
# 挂载频道数据(可选,用于更新)
|
||||
- ../ui/dist-web/api:/app/public/api:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# 可选:使用 Nginx 作为反向代理
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: iptv-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- iptv-web
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- nginx
|
||||
87
web/nginx/nginx.conf
Normal file
87
web/nginx/nginx.conf
Normal file
@ -0,0 +1,87 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
|
||||
# 上游服务
|
||||
upstream iptv_backend {
|
||||
server iptv-web:3000;
|
||||
}
|
||||
|
||||
# HTTP 服务器
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
proxy_pass http://iptv_backend;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API 和代理
|
||||
location / {
|
||||
proxy_pass http://iptv_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 流媒体代理(长连接)
|
||||
location /proxy/ {
|
||||
proxy_pass http://iptv_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# 长超时(流媒体需要)
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 服务器(需要证书)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://iptv_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
web/package.json
Normal file
20
web/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "iptv-web-server",
|
||||
"version": "1.0.0",
|
||||
"description": "IPTV Web 服务端 - 代理 M3U 和流媒体",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"m3u8-parser": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
1004
web/pnpm-lock.yaml
generated
Normal file
1004
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
155
web/src/index.js
Normal file
155
web/src/index.js
Normal file
@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 获取频道列表
|
||||
app.get('/api/channels', async (req, res) => {
|
||||
try {
|
||||
const dataDir = path.join(__dirname, '../../ui/dist-web/api');
|
||||
const filePath = path.join(dataDir, 'result.txt');
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(content);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Channel data not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading channels:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 代理 M3U8 流(解决跨域)
|
||||
app.get('/proxy/m3u8', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'Missing url parameter' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Proxy] M3U8: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
|
||||
'Referer': new URL(url).origin
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// 转换相对路径为绝对路径
|
||||
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
|
||||
const modifiedContent = content.replace(
|
||||
/^(?!#)([^\s].*\.ts.*)$/gm,
|
||||
(match) => {
|
||||
if (match.startsWith('http')) return match;
|
||||
return `/proxy/ts?url=${encodeURIComponent(baseUrl + match)}`;
|
||||
}
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.send(modifiedContent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Proxy] Error:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 代理 TS 流
|
||||
app.get('/proxy/ts', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'Missing url parameter' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
response.headers.forEach((value, name) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
|
||||
response.body.pipe(res);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Proxy] TS Error:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 通用代理(用于其他类型的流)
|
||||
app.get('/proxy/stream', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'Missing url parameter' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Proxy] Stream: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
response.headers.forEach((value, name) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
|
||||
response.body.pipe(res);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Proxy] Stream Error:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 IPTV Web Server running on port ${PORT}`);
|
||||
console.log(`📺 Access: http://localhost:${PORT}`);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user