feat(web): 创建 Docker Web 服务端

- 添加 Express 服务端,提供频道 API
- 添加 M3U8/TS 流代理,解决跨域问题
- 添加 Dockerfile 和 docker-compose.yml
- 添加 Nginx 反向代理配置
- 支持多阶段构建,自动打包前端
This commit is contained in:
李岩岩 2026-02-05 12:41:50 +08:00
parent 2a565fb8da
commit 52fc8099ae
8 changed files with 1425 additions and 0 deletions

7
web/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md

32
web/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

155
web/src/index.js Normal file
View 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}`);
});