Mystique/.forgejo/scripts/check_m3u_links.js

319 lines
No EOL
11 KiB
JavaScript

/**
* M3U Live Stream Dead Link Checker
* Checks live stream URLs in an M3U playlist and logs dead streams for Forgejo Actions.
*/
const fs = require('fs');
const https = require('https');
const http = require('http');
const { URL } = require('url');
class M3UChecker {
constructor() {
// Configuration
this.M3U_FILE = 'mystique.m3u';
this.REQUEST_TIMEOUT = 10000; // 10 seconds
this.MAX_RETRIES = 2;
this.DELAY_BETWEEN_REQUESTS = 1000; // 1 second
this.USER_AGENT = 'M3U-Checker/1.0';
}
/**
* Parse M3U file and extract stream URLs with metadata
*/
parseM3U(filepath) {
if (!fs.existsSync(filepath)) {
throw new Error(`${filepath} not found!`);
}
const content = fs.readFileSync(filepath, 'utf8');
const lines = content.split('\n').map(line => line.trim()).filter(line => line);
const entries = [];
let currentExtinf = null;
for (const line of lines) {
if (line.startsWith('#EXTINF:')) {
// Parse EXTINF line with better regex to handle attributes
// Format: #EXTINF:duration attribute="value" attribute="value",title
const extinfMatch = line.match(/^#EXTINF:(.+)$/);
if (extinfMatch) {
const extinfContent = extinfMatch[1];
// Find the last comma which separates attributes from title
const lastCommaIndex = extinfContent.lastIndexOf(',');
if (lastCommaIndex !== -1) {
const attributesPart = extinfContent.substring(0, lastCommaIndex).trim();
const title = extinfContent.substring(lastCommaIndex + 1).trim();
// Extract group-title if present
const groupMatch = attributesPart.match(/group-title="([^"]+)"/);
const groupTitle = groupMatch ? groupMatch[1] : '';
currentExtinf = {
title: title,
groupTitle: groupTitle
};
} else {
// Fallback for malformed EXTINF
currentExtinf = {
title: extinfContent,
groupTitle: ''
};
}
} else {
currentExtinf = {
title: 'Unknown',
groupTitle: ''
};
}
} else if (!line.startsWith('#') && this.isValidUrl(line)) {
// This should be a stream URL
const entry = {
url: line,
title: currentExtinf ? currentExtinf.title : 'Unknown',
groupTitle: currentExtinf ? currentExtinf.groupTitle : ''
};
entries.push(entry);
currentExtinf = null;
}
}
return entries;
}
/**
* Basic URL validation
*/
isValidUrl(urlString) {
try {
new URL(urlString);
return true;
} catch {
return false;
}
}
/**
* Check if stream URL is accessible
* Returns Promise<{isAlive: boolean, error: string, statusCode: number}>
*/
async checkUrl(url) {
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
try {
const result = await this.makeRequest(url);
if (result.statusCode < 400) {
return { isAlive: true, error: '', statusCode: result.statusCode };
} else {
return { isAlive: false, error: `HTTP ${result.statusCode}`, statusCode: result.statusCode };
}
} catch (error) {
if (attempt === this.MAX_RETRIES - 1) {
return { isAlive: false, error: error.message, statusCode: 0 };
}
// Wait before retry
await this.sleep(this.DELAY_BETWEEN_REQUESTS * (attempt + 1));
}
}
}
/**
* Make HTTP request with timeout
*/
makeRequest(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const lib = isHttps ? https : http;
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'HEAD',
timeout: this.REQUEST_TIMEOUT,
headers: {
'User-Agent': this.USER_AGENT
}
};
const req = lib.request(options, (res) => {
// If HEAD is not allowed, try GET with range
if (res.statusCode === 405) {
this.makeGetRequest(url).then(resolve).catch(reject);
return;
}
resolve({ statusCode: res.statusCode });
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Timeout after ${this.REQUEST_TIMEOUT}ms`));
});
req.on('error', (error) => {
if (error.code === 'ECONNREFUSED') {
reject(new Error('Connection refused'));
} else if (error.code === 'ENOTFOUND') {
reject(new Error('Host not found'));
} else if (error.code === 'ETIMEDOUT') {
reject(new Error('Connection timeout'));
} else {
reject(new Error(`Connection error: ${error.message}`));
}
});
req.end();
});
}
/**
* Make GET request with range header (fallback for servers that don't support HEAD)
*/
makeGetRequest(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const lib = isHttps ? https : http;
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: this.REQUEST_TIMEOUT,
headers: {
'User-Agent': this.USER_AGENT,
'Range': 'bytes=0-1024'
}
};
const req = lib.request(options, (res) => {
// Consume response to prevent memory leak
res.on('data', () => {});
res.on('end', () => {
resolve({ statusCode: res.statusCode });
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Timeout after ${this.REQUEST_TIMEOUT}ms`));
});
req.on('error', (error) => {
reject(error);
});
req.end();
});
}
/**
* Sleep utility
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Log dead stream information
*/
logDeadLink(entry, error, statusCode) {
console.log(`\n🔴 DEAD STREAM FOUND:`);
console.log(` Title: ${entry.title}`);
if (entry.groupTitle) {
console.log(` Group: ${entry.groupTitle}`);
}
console.log(` URL: ${entry.url}`);
console.log(` Error: ${error}`);
console.log(` Status Code: ${statusCode || 'N/A'}`);
}
/**
* Main function to check M3U live streams
*/
async runCheck() {
console.log(`🎵 M3U Live Stream Dead Link Checker`);
console.log(`=====================================`);
try {
console.log(`📂 Parsing ${this.M3U_FILE}...`);
const entries = this.parseM3U(this.M3U_FILE);
console.log(`🔍 Found ${entries.length} streams to check\n`);
if (entries.length === 0) {
console.log('❌ No stream URLs found in playlist');
return;
}
const deadStreams = [];
let aliveCount = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const progress = `[${(i + 1).toString().padStart(3)}/${entries.length}]`;
// Show group title in progress if available
const displayTitle = entry.groupTitle ?
`${entry.title} (${entry.groupTitle})` : entry.title;
console.log(`${progress} Checking: ${displayTitle}`);
const result = await this.checkUrl(entry.url);
if (result.isAlive) {
aliveCount++;
console.log(` ✅ Live (${result.statusCode})`);
} else {
deadStreams.push({ entry, error: result.error, statusCode: result.statusCode });
console.log(` ❌ Dead (${result.error})`);
// Log dead stream details immediately
this.logDeadLink(entry, result.error, result.statusCode);
}
// Add delay between requests
if (i < entries.length - 1) {
await this.sleep(this.DELAY_BETWEEN_REQUESTS);
}
}
// Final summary
console.log(`\n📊 CHECK COMPLETED`);
console.log(`===================`);
console.log(`✅ Live: ${aliveCount}`);
console.log(`❌ Dead: ${deadStreams.length}`);
console.log(`📈 Success Rate: ${((aliveCount / entries.length) * 100).toFixed(1)}%`);
if (deadStreams.length > 0) {
console.log(`\n🚨 DEAD STREAMS SUMMARY:`);
console.log(`=========================`);
deadStreams.forEach(({ entry, error }, index) => {
console.log(`${(index + 1).toString().padStart(2)}. ${entry.title}`);
if (entry.groupTitle) {
console.log(` Group: ${entry.groupTitle}`);
}
console.log(` URL: ${entry.url}`);
console.log(` Error: ${error}`);
console.log('');
});
} else {
console.log(`\n🎉 All streams are live! No issues found.`);
}
} catch (error) {
console.error(`❌ Error during check: ${error.message}`);
process.exit(1);
}
}
}
// Run the checker
if (require.main === module) {
const checker = new M3UChecker();
checker.runCheck().catch(error => {
console.error(`💥 Fatal error: ${error.message}`);
process.exit(1);
});
}