/** * M3U Playlist Dead Link Checker * Checks URLs in an M3U playlist and logs dead links for Forgejo Actions. */ const fs = require('fs'); const https = require('https'); const http = require('http'); const { URL } = require('url'); // Configuration const M3U_FILE = 'mystique.m3u'; const REQUEST_TIMEOUT = 10000; // 10 seconds const MAX_RETRIES = 2; const DELAY_BETWEEN_REQUESTS = 1000; // 1 second const USER_AGENT = 'M3U-Checker/1.0'; class M3UChecker { constructor() { this.repoOwner = process.env.REPO_OWNER; this.repoName = process.env.REPO_NAME; } /** * Parse M3U file and extract 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: #EXTINF:duration,title const match = line.match(/^#EXTINF:([^,]*),(.+)$/); if (match) { currentExtinf = { duration: match[1].trim(), title: match[2].trim() }; } else { currentExtinf = { duration: '', title: 'Unknown' }; } } else if (!line.startsWith('#') && this.isValidUrl(line)) { // This should be a URL const entry = { url: line, title: currentExtinf ? currentExtinf.title : 'Unknown', duration: currentExtinf ? currentExtinf.duration : '' }; entries.push(entry); currentExtinf = null; } } return entries; } /** * Basic URL validation */ isValidUrl(urlString) { try { new URL(urlString); return true; } catch { return false; } } /** * Check if URL is accessible * Returns Promise<{isAlive: boolean, error: string, statusCode: number}> */ async checkUrl(url) { for (let attempt = 0; attempt < 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 === MAX_RETRIES - 1) { return { isAlive: false, error: error.message, statusCode: 0 }; } // Wait before retry await this.sleep(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: REQUEST_TIMEOUT, headers: { 'User-Agent': 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 ${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: REQUEST_TIMEOUT, headers: { 'User-Agent': 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 ${REQUEST_TIMEOUT}ms`)); }); req.on('error', (error) => { reject(error); }); req.end(); }); } /** * Sleep utility */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Log dead link information */ logDeadLink(entry, error, statusCode) { console.log(`\nšŸ”“ DEAD LINK FOUND:`); console.log(` Title: ${entry.title}`); console.log(` URL: ${entry.url}`); console.log(` Error: ${error}`); console.log(` Status Code: ${statusCode || 'N/A'}`); console.log(` Duration: ${entry.duration || 'Unknown'}`); console.log(` Repository: ${this.repoOwner}/${this.repoName}`); } /** * Main function to check M3U playlist */ async runCheck() { console.log(`šŸŽµ M3U Playlist Dead Link Checker`); console.log(`=====================================`); try { console.log(`šŸ“‚ Parsing ${M3U_FILE}...`); const entries = this.parseM3U(M3U_FILE); console.log(`šŸ” Found ${entries.length} URLs to check\n`); if (entries.length === 0) { console.log('āŒ No URLs found in playlist'); return; } const deadLinks = []; let aliveCount = 0; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const progress = `[${(i + 1).toString().padStart(3)}/${entries.length}]`; console.log(`${progress} Checking: ${entry.title}`); const result = await this.checkUrl(entry.url); if (result.isAlive) { aliveCount++; console.log(` āœ… Alive (${result.statusCode})`); } else { deadLinks.push({ entry, error: result.error, statusCode: result.statusCode }); console.log(` āŒ Dead (${result.error})`); // Log dead link details immediately this.logDeadLink(entry, result.error, result.statusCode); } // Add delay between requests if (i < entries.length - 1) { await this.sleep(DELAY_BETWEEN_REQUESTS); } } // Final summary console.log(`\nšŸ“Š CHECK COMPLETED`); console.log(`===================`); console.log(`āœ… Alive: ${aliveCount}`); console.log(`āŒ Dead: ${deadLinks.length}`); console.log(`šŸ“ˆ Success Rate: ${((aliveCount / entries.length) * 100).toFixed(1)}%`); if (deadLinks.length > 0) { console.log(`\n🚨 DEAD LINKS SUMMARY:`); console.log(`=======================`); deadLinks.forEach(({ entry, error }, index) => { console.log(`${(index + 1).toString().padStart(2)}. ${entry.title}`); console.log(` URL: ${entry.url}`); console.log(` Error: ${error}`); console.log(''); }); } else { console.log(`\nšŸŽ‰ All links are alive! 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); }); }