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