const { spawn } = require("child_process") const express = require("express") const fs = require("fs") const cors = require("cors") const path = require("path") const axios = require("axios") const PORT = 7860 const app = express() const baseOutputDir = path.join(__dirname, "hls_output") const publicDir = path.join(__dirname, "public") const transitionVideo = "https://files.catbox.moe/hvl1cv.mp4" const watermarkText = "AnitakuX" const channelsFile = path.join(__dirname, "channels.json") app.use(cors()) // Enhanced CORS configuration app.use( cors({ origin: ["http://localhost:7860", "http://127.0.0.1:7860", "http://localhost:3000"], credentials: true, }), ) // Add headers for better streaming app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*") res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept") res.header("Cache-Control", "no-cache, no-store, must-revalidate") res.header("Pragma", "no-cache") res.header("Expires", "0") next() }) // Load channels from JSON file let channels = {} try { channels = JSON.parse(fs.readFileSync(channelsFile)) } catch (err) { channels = { channel1: { name: "Anitaku TV 1", playlist: [ { title: "Lazarus", start: 1, end: 12 }, { title: "Erased", start: 1, end: 12 }, ], schedule: [], }, channel2: { name: "Anitaku TV 2", playlist: [{ title: "Eminence in Shadow", start: 1, end: 20 }], schedule: [], }, channel3: { name: "Anitaku TV 3", playlist: [{ title: "Attack on Titan", start: 1, end: 25 }], schedule: [], }, channel4: { name: "Anitaku TV 4", playlist: [{ title: "Demon Slayer", start: 1, end: 26 }], schedule: [], }, channel5: { name: "Anitaku TV 5", playlist: [{ title: "Chained Soldier", start: 1, end: 11 }], schedule: [], }, channel6: { name: "Anitaku TV 6", playlist: [{ title: "Solo Leveling", start: 1, end: 13 }], schedule: [], }, } fs.writeFileSync(channelsFile, JSON.stringify(channels, null, 2)) } // Serve static files from public directory app.use(express.static(publicDir)) app.use(express.json()) // Helper function to format time in WAT (UTC+1) function formatWATTime(date) { const watOffset = 1 * 60 * 60 * 1000 const watDate = new Date(date.getTime() + watOffset) return watDate.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", }) } // Get video duration using ffprobe async function getVideoDuration(url) { return new Promise((resolve) => { const ffprobe = spawn("ffprobe", ["-v", "quiet", "-print_format", "json", "-show_format", url]) let output = "" ffprobe.stdout.on("data", (data) => { output += data.toString() }) ffprobe.on("close", (code) => { try { if (code === 0) { const info = JSON.parse(output) const duration = Number.parseFloat(info.format.duration) resolve(duration * 1000) } else { console.log(`Failed to get duration for ${url}, using default 22 minutes`) resolve(22 * 60 * 1000) } } catch (err) { console.log(`Error parsing duration for ${url}, using default 22 minutes`) resolve(22 * 60 * 1000) } }) ffprobe.on("error", () => { console.log(`ffprobe error for ${url}, using default 22 minutes`) resolve(22 * 60 * 1000) }) }) } // Generate schedule for a channel async function generateSchedule(channelConfig) { const schedule = [] let currentTime = new Date() for (const anime of channelConfig.playlist) { for (let ep = anime.start; ep <= anime.end; ep++) { const startTime = new Date(currentTime) let duration = 22 * 60 * 1000 try { const mp4 = await getEpisodeMp4(anime.title, ep) if (mp4) { duration = await getVideoDuration(mp4) } } catch (err) { console.log(`Could not get duration for ${anime.title} Episode ${ep}, using default`) } const totalDuration = duration + 3 * 60 * 1000 const endTime = new Date(currentTime.getTime() + totalDuration) schedule.push({ title: `${anime.title} Episode ${ep}`, startTime: formatWATTime(startTime), endTime: formatWATTime(endTime), duration: Math.round(duration / (60 * 1000)), }) currentTime = new Date(endTime.getTime() + 1000) } } return schedule } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } async function getEpisodeMp4(anime, ep) { const url = `https://txtorg-anihx.hf.space/api/episode?anime=${encodeURIComponent(anime)}&ep=${ep}` const data = await axios .get(url) .then((r) => r.data) .catch(() => null) const dl = data?.links?.sub?.["360p_download"] if (!dl) return null return await axios .get(`https://txtorg-anihx.hf.space/resolvex?url=${dl}`) .then((r) => r.data?.mp4Link) .catch(() => null) } // Enhanced FFmpeg function with better streaming parameters function startFFmpeg(channelId, inputUrl, outputDir, onExit, isLoop = false, isTransition = false) { const baseArgs = ["-re"] if (isLoop) { baseArgs.push("-stream_loop", "-1") } // Enhanced streaming parameters for seamless playback const args = [ ...baseArgs, "-i", inputUrl, "-vf", `drawtext=text='${watermarkText}':fontcolor=white:fontsize=24:x=w-tw-20:y=20`, "-c:v", "libx264", "-preset", "veryfast", // Balance between speed and quality "-tune", "zerolatency", "-profile:v", "baseline", "-level", "3.0", "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-g", "50", // Increased GOP for stability "-keyint_min", "25", "-sc_threshold", "0", "-f", "hls", "-hls_time", "4", // Increased segment time for stability "-hls_list_size", "6", // Reasonable playlist size "-hls_flags", "delete_segments+program_date_time", "-hls_segment_type", "mpegts", "-hls_segment_filename", path.join(outputDir, "segment_%03d.ts"), "-hls_allow_cache", "1", "-hls_base_url", `http://localhost:7860/hls/${channelId}/`, path.join(outputDir, "stream.m3u8"), ] console.log( `🔴 [${channelId}] Starting FFmpeg for ${inputUrl}${isLoop ? " (LOOP)" : ""}${isTransition ? " (TRANSITION)" : ""}`, ) const proc = spawn("ffmpeg", args) proc.stderr.on("data", (d) => { const output = d.toString() // Only log important messages to reduce noise if (output.includes("error") || output.includes("Error") || output.includes("failed")) { console.error(`[FFMPEG ${channelId}] ${output}`) } }) proc.on("exit", (code) => { console.log(`[FFMPEG ${channelId}] Process exited with code ${code}`) if (onExit) onExit(code) }) return proc } function setupChannel(channelId, channelConfig) { const state = { index: 0, ep: channelConfig.playlist[0].start, isTransition: false, process: null, anime: channelConfig.playlist[0].title, currentEp: 1, nextEpisodeReady: false, nextEpisodeUrl: null, transitionStartTime: null, preloadQueue: [], // Queue for preloaded episodes isPreloading: false, } const channelOutput = path.join(baseOutputDir, channelId) if (fs.existsSync(channelOutput)) fs.rmSync(channelOutput, { recursive: true, force: true }) fs.mkdirSync(channelOutput, { recursive: true }) // Generate initial schedule generateSchedule(channelConfig).then((schedule) => { channelConfig.schedule = schedule }) // Enhanced preloading system async function preloadNextEpisodes() { if (state.isPreloading) return state.isPreloading = true try { // Preload next 2-3 episodes const episodesToPreload = 3 const currentEntry = channelConfig.playlist[state.index] for (let i = 1; i <= episodesToPreload; i++) { let nextEp = state.ep + i let nextIndex = state.index // Handle playlist wraparound while (nextEp > channelConfig.playlist[nextIndex].end) { nextEp = nextEp - channelConfig.playlist[nextIndex].end + channelConfig.playlist[nextIndex].start - 1 nextIndex++ if (nextIndex >= channelConfig.playlist.length) { nextIndex = 0 nextEp = channelConfig.playlist[nextIndex].start + (nextEp - 1) } } const nextEntry = channelConfig.playlist[nextIndex] const cacheKey = `${nextEntry.title}_${nextEp}` // Skip if already in queue if (state.preloadQueue.find((item) => item.key === cacheKey)) continue console.log(`[${channelId}] 🔄 Preloading: ${nextEntry.title} Episode ${nextEp}`) try { const nextUrl = await getEpisodeMp4(nextEntry.title, nextEp) if (nextUrl) { state.preloadQueue.push({ key: cacheKey, url: nextUrl, title: nextEntry.title, episode: nextEp, timestamp: Date.now(), }) console.log(`[${channelId}] ✅ Preloaded: ${nextEntry.title} Episode ${nextEp}`) } } catch (err) { console.log(`[${channelId}] ❌ Failed to preload: ${nextEntry.title} Episode ${nextEp}`) } } // Clean old preloaded episodes (keep only recent ones) const maxAge = 10 * 60 * 1000 // 10 minutes state.preloadQueue = state.preloadQueue.filter((item) => Date.now() - item.timestamp < maxAge) } finally { state.isPreloading = false } } // Get next episode URL from preload queue or fetch fresh async function getNextEpisodeUrl() { const currentEntry = channelConfig.playlist[state.index] let nextEp = state.ep + 1 let nextIndex = state.index if (nextEp > currentEntry.end) { nextIndex++ if (nextIndex >= channelConfig.playlist.length) nextIndex = 0 nextEp = channelConfig.playlist[nextIndex].start } const nextEntry = channelConfig.playlist[nextIndex] const cacheKey = `${nextEntry.title}_${nextEp}` // Check preload queue first const preloaded = state.preloadQueue.find((item) => item.key === cacheKey) if (preloaded) { console.log(`[${channelId}] 🚀 Using preloaded: ${nextEntry.title} Episode ${nextEp}`) return preloaded.url } // Fallback to fresh fetch console.log(`[${channelId}] 📡 Fetching fresh: ${nextEntry.title} Episode ${nextEp}`) return await getEpisodeMp4(nextEntry.title, nextEp) } async function seamlessTransition() { console.log(`[${channelId}] 🔄 Starting seamless transition...`) // Get next episode URL with retry logic let nextUrl = null let retryCount = 0 const maxRetries = 3 while (!nextUrl && retryCount < maxRetries) { try { nextUrl = await getNextEpisodeUrl() if (nextUrl) break } catch (error) { console.log(`[${channelId}] ❌ Retry ${retryCount + 1}/${maxRetries} failed:`, error.message) } retryCount++ if (retryCount < maxRetries) { await wait(2000 * retryCount) // Exponential backoff } } if (nextUrl) { console.log(`[${channelId}] ⚡ Quick transition to next episode`) // Kill current process gracefully if (state.process) { state.process.kill("SIGTERM") await wait(1000) // Wait for graceful shutdown } // Move to next episode const currentEntry = channelConfig.playlist[state.index] state.ep++ if (state.ep > currentEntry.end) { state.index++ if (state.index >= channelConfig.playlist.length) state.index = 0 state.ep = channelConfig.playlist[state.index].start } state.anime = channelConfig.playlist[state.index].title state.currentEp = state.ep state.isTransition = false // Start next episode with error handling try { state.process = startFFmpeg(channelId, nextUrl, channelOutput, async (code) => { console.log(`[${channelId}] ✅ Finished: ${state.anime} Ep${state.ep} (exit code: ${code})`) preloadNextEpisodes() await wait(1000) // Brief pause between episodes seamlessTransition() }) } catch (error) { console.log(`[${channelId}] ❌ Failed to start next episode:`, error.message) // Fallback to transition video startTransitionFallback() } } else { startTransitionFallback() } } // Fallback transition function function startTransitionFallback() { console.log(`[${channelId}] 📺 Using transition video fallback`) state.isTransition = true state.transitionStartTime = Date.now() state.process = startFFmpeg( channelId, transitionVideo, channelOutput, () => { // Transition ended, try again }, true, true, ) // Monitor transition with timeout const transitionTimeout = setTimeout(async () => { console.log(`[${channelId}] ⏰ Transition timeout, moving to next episode`) if (state.process) { state.process.kill("SIGTERM") } // Move to next episode const currentEntry = channelConfig.playlist[state.index] state.ep++ if (state.ep > currentEntry.end) { state.index++ if (state.index >= channelConfig.playlist.length) state.index = 0 state.ep = channelConfig.playlist[state.index].start } await wait(2000) seamlessTransition() }, 30000) // 30 second timeout // Clear timeout if transition ends naturally state.transitionTimeout = transitionTimeout } async function startChannel() { console.log(`[${channelId}] 🚀 Starting channel...`) // Start preloading episodes preloadNextEpisodes() // Start with first episode const entry = channelConfig.playlist[state.index] state.anime = entry.title state.currentEp = state.ep const mp4 = await getEpisodeMp4(entry.title, state.ep) if (!mp4) { console.log(`[${channelId}] ❌ First episode not found, starting with transition`) state.isTransition = true state.transitionStartTime = Date.now() state.process = startFFmpeg( channelId, transitionVideo, channelOutput, () => { seamlessTransition() }, true, true, ) return } state.process = startFFmpeg(channelId, mp4, channelOutput, async (code) => { console.log(`[${channelId}] ✅ Finished: ${entry.title} Ep${state.ep}`) seamlessTransition() }) } // Start the channel startChannel() // Serve stream app.use(`/hls/${channelId}`, express.static(channelOutput)) } // Health check endpoint app.get("/api/health", (req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString(), channels: Object.keys(channels), }) }) // API Routes app.post("/api/add-anime", async (req, res) => { const { channelId, title, start, end } = req.body if (!channels[channelId]) { return res.status(400).json({ error: "Invalid channel ID" }) } channels[channelId].playlist.push({ title, start: Number.parseInt(start), end: Number.parseInt(end) }) channels[channelId].schedule = await generateSchedule(channels[channelId]) fs.writeFileSync(channelsFile, JSON.stringify(channels, null, 2)) res.json({ success: true }) }) app.get("/watch/:channelId", (req, res) => { const filePath = path.join(publicDir, "channel.html") res.sendFile(filePath) }) app.get("/api/schedule/:channelId", (req, res) => { const channelId = req.params.channelId if (!channels[channelId]) { return res.status(400).json({ error: "Invalid channel ID" }) } res.json(channels[channelId].schedule) }) app.get("/api/current/:channelId", (req, res) => { const channelId = req.params.channelId if (!channels[channelId]) { return res.status(400).json({ error: "Invalid channel ID" }) } res.json({ channelName: channels[channelId].name, status: "live", }) }) // Launch all channels for (const [id, config] of Object.entries(channels)) { setupChannel(id, config) } // Create public directory if it doesn't exist if (!fs.existsSync(publicDir)) { fs.mkdirSync(publicDir, { recursive: true }) } app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`) console.log(`🔗 Channels:`) Object.keys(channels).forEach((id) => { console.log(`- http://localhost:${PORT}/watch/${id}`) }) console.log(`- http://localhost:${PORT}/add-anime`) })