diff --git a/.forgejo/scripts/check_m3u_links.js b/.forgejo/scripts/check_m3u_links.js new file mode 100644 index 0000000..b81784d --- /dev/null +++ b/.forgejo/scripts/check_m3u_links.js @@ -0,0 +1,291 @@ +/** + * 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); + }); +} \ No newline at end of file