hls / index.js
newservers's picture
Update index.js
8d73890 verified
Raw
History Blame Contribute Delete
16.7 kB
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`)
})