ระบบตรวจจับสถานะผู้ชมแบบเรียลไทม์ผ่านระดับ Network Socket (ss) และ Nginx Access Log
ss สแกนตรวจจับท่อ TCP Connection ที่กำลังเปิดดูไฟล์ MPEG-TS ค้างอยู่ แม้ Nginx ยังไม่ Flush Log ลงดิสก์สร้างโฟลเดอร์สำหรับรัน Dashboard และติดตั้งโมดูลที่จำเป็น:
sudo mkdir -p /var/www/html/mistserver-monitor
cd /var/www/html/mistserver-monitor
sudo npm init -y
sudo npm install express cors
สร้างไฟล์ /var/www/html/mistserver-monitor/server.js ด้วยโค้ดชุดล่าสุด:
cat << 'EOF' > /var/www/html/mistserver-monitor/server.js
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const app = express();
app.use(cors());
app.use(express.json());
const PORT = 4000;
const NGINX_LOG_PATH = '/var/log/nginx/access.log';
const LOG_FILE_PATH = path.join(__dirname, 'stream_logs.json');
let liveViewersMap = new Map();
let ipToStreamNameMaster = new Map();
function scanPersistentConnections() {
exec("ss -tnp state established '( sport = :80 or dport = :80 )'", function(error, stdout, stderr) {
let output = stdout;
if (error) {
exec("netstat -anp | grep :80 | grep ESTABLISHED", function(err, out) {
if (!err) processActiveIps(out);
});
return;
}
processActiveIps(output);
});
}
function processActiveIps(output) {
if (!output) return;
const lines = output.split('\n');
const currentlyConnectedIps = new Set();
lines.forEach(function(line) {
const ipMatches = line.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}):[0-9]+/g);
if (ipMatches) {
ipMatches.forEach(function(matchedStr) {
const ip = matchedStr.split(':')[0];
if (ip !== '127.0.0.1' && ip !== '172.30.100.9') {
currentlyConnectedIps.add(ip);
}
});
}
});
const now = Date.now();
currentlyConnectedIps.forEach(function(ip) {
let streamName = ipToStreamNameMaster.get(ip);
if (!streamName) {
for (let [key, val] of liveViewersMap.entries()) {
if (key.startsWith(ip + "_")) {
streamName = val.stream;
break;
}
}
}
if (!streamName) streamName = 'pptv';
// เคลียร์ช่องเก่าของ IP นี้ออกทันทีเมื่อเปลี่ยนไปดูช่องอื่น
for (let [k, v] of liveViewersMap.entries()) {
if (v.host === ip && v.stream !== streamName) {
liveViewersMap.delete(k);
}
}
const key = ip + "_" + streamName;
const exist = liveViewersMap.get(key);
if (!exist) {
saveToHistoryJson(ip, streamName, 'MPEG-TS (.ts)');
}
liveViewersMap.set(key, {
host: ip,
stream: streamName,
protocol: 'MPEG-TS Stream',
last_seen: now
});
});
for (let [key, value] of liveViewersMap.entries()) {
if (!currentlyConnectedIps.has(value.host) && (now - value.last_seen > 45000)) {
liveViewersMap.delete(key);
}
}
}
function extractStreamInfo(line) {
if (!line || (line.indexOf('.ts') === -1 && line.indexOf('.m3u8') === -1)) return null;
try {
const ipMatch = line.match(/^([0-9a-fA-F.:]+)/);
const ip = ipMatch ? ipMatch[1] : null;
if (!ip || ip === '127.0.0.1' || ip === '172.30.100.9') return null;
let streamName = 'Unknown';
const urlMatch = line.match(/\"(GET|POST|HEAD)\s+\/([^?\s]+)/);
if (urlMatch && urlMatch[2]) {
const parts = urlMatch[2].split(/[\/.]/);
streamName = (parts[0] === 'hls' && parts[1]) ? parts[1] : parts[0];
}
if (streamName === 'Unknown' || streamName === '') {
if (line.toLowerCase().indexOf('pptv') !== -1) streamName = 'pptv';
else if (line.toLowerCase().indexOf('nbt') !== -1) streamName = 'nbt';
}
return { ip, streamName };
} catch(e) { return null; }
}
setInterval(scanPersistentConnections, 3000);
if (fs.existsSync(NGINX_LOG_PATH)) {
let currentFileSize = fs.statSync(NGINX_LOG_PATH).size;
fs.watch(path.dirname(NGINX_LOG_PATH), function(eventType, filename) {
if (filename !== path.basename(NGINX_LOG_PATH)) return;
try {
const stats = fs.statSync(NGINX_LOG_PATH);
if (stats.size <= currentFileSize) { currentFileSize = stats.size; return; }
const logStream = fs.createReadStream(NGINX_LOG_PATH, { start: currentFileSize, end: stats.size });
let buffer = '';
logStream.on('data', function(chunk) {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop();
lines.forEach(function(line) {
const parsed = extractStreamInfo(line);
if (parsed && parsed.streamName !== 'Unknown') {
ipToStreamNameMaster.set(parsed.ip, parsed.streamName);
for (let [k, v] of liveViewersMap.entries()) {
if (v.host === parsed.ip && v.stream !== parsed.streamName) {
liveViewersMap.delete(k);
}
}
const key = parsed.ip + "_" + parsed.streamName;
liveViewersMap.set(key, {
host: parsed.ip,
stream: parsed.streamName,
protocol: line.indexOf('.m3u8') !== -1 ? 'HLS (.m3u8)' : 'MPEG-TS (.ts)',
last_seen: Date.now()
});
}
});
});
logStream.on('end', function() { currentFileSize = stats.size; });
} catch(e){}
});
}
function saveToHistoryJson(ip, streamName, protocol) {
let logs = [];
if (fs.existsSync(LOG_FILE_PATH)) {
try { logs = JSON.parse(fs.readFileSync(LOG_FILE_PATH, 'utf8')); } catch (e) { logs = []; }
}
const newLog = {
id: Date.now() + Math.random().toString(36).substr(2, 5),
ip: ip,
stream_name: streamName,
protocol: protocol,
connected_at: new Date().toISOString()
};
logs.unshift(newLog);
if (logs.length > 300) logs = logs.slice(0, 300);
fs.writeFileSync(LOG_FILE_PATH, JSON.stringify(logs, null, 2), 'utf8');
}
setInterval(() => { ipToStreamNameMaster.clear(); }, 43200000);
app.get('/', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });
app.get('/api/live-viewers', function(req, res) {
const activeList = Array.from(liveViewersMap.values()).map(function(v) {
return { host: v.host, stream: v.stream, protocol: v.protocol, conntime: "ONLINE" };
});
res.json(activeList);
});
app.get('/api/logs', function(req, res) {
if (!fs.existsSync(LOG_FILE_PATH)) return res.json([]);
try { res.json(JSON.parse(fs.readFileSync(LOG_FILE_PATH, 'utf8'))); } catch (e) { res.json([]); }
});
app.listen(PORT, function() {
console.log(`🚀 Ultimate Persistent .TS Monitor running on port ${PORT}`);
});
EOF
สั่งให้โปรเซสทำงานเบื้องหลังด้วยสิทธิ์ root เพื่อให้สามารถรันคำสั่งตรวจสอบ Socket (ss) ได้อย่างสมบูรณ์:
# ติดตั้ง PM2 (หากยังไม่มี)
sudo npm install -g pm2
# สั่งเริ่มการทำงาน Service
sudo pm2 start /var/www/html/mistserver-monitor/server.js --name mist-monitor
# ตั้งค่าให้ออโต้รันตอนเปิดเครื่อง
sudo pm2 save
sudo pm2 startup
ตรวจสอบสถานะโปรเซสผ่าน PM2:
sudo pm2 status mist-monitor
sudo pm2 logs mist-monitor
ทดสอบดึงข้อมูล JSON ของผู้ชมสดผ่าน API:
curl -s http://127.0.0.1:4000/api/live-viewers | jq .