Spaces:
Paused
Paused
| 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`) | |
| }) | |