lib: m3u8-server (#398)

* feat: Create a M3U8 server to use a option like skip_initial_bytes of ffmpeg

* Extract headers from the HTTP session

* minor improvement

* early return if data.size < 3 before array access to avoid potential bounds checking overhead in tight loops

* Simplify detection logic

* Only detectSkipBytes over actual bytes read instead of the full buffer

* Use regex for explicit file extension check

* correct readme

* Remove `isInitialized` since it cause unable to start server again after stop

* properly close response to avoid memory leaks

* convert class to object

* Properly working M3U8 Server

* HiAnime fix HD-3 server with M3U8 Server

* Handle relative link

* Refactor m3u8 server package structure and lint

* Refactor M3U8 server components for improved URL handling and code clarity

---------

Co-authored-by: WebDitto <webditto@proton.me>
This commit is contained in:
Cuong-Tran
2026-05-31 15:24:28 +07:00
committed by GitHub
parent ce2e04c460
commit 2dbdab3583
18 changed files with 817 additions and 20 deletions

View File

@@ -7,5 +7,5 @@ plugins {
baseVersionCode = 4
dependencies {
implementation(project(":lib:megaupextractor"))
api(project(":lib:megaupextractor"))
}

View File

@@ -256,7 +256,7 @@ abstract class ZoroTheme(
return embedLinks.parallelCatchingFlatMap(::extractVideo)
}
abstract fun extractVideo(server: VideoData): List<Video>
abstract suspend fun extractVideo(server: VideoData): List<Video>
override fun videoListSelector() = throw UnsupportedOperationException()

122
lib/m3u8server/README.md Normal file
View File

@@ -0,0 +1,122 @@
# M3U8 Server Library
A local HTTP server library for processing M3U8 files with automatic byte detection and header preservation.
## Features
- **Real HTTP Server**: Runs a NanoHTTPD server on localhost to process M3U8 files
- **Random Port Assignment**: Automatically selects an available port to avoid conflicts
- **Automatic Detection**: Automatically detects and skips fake headers in video segments
- **Header Preservation**: Preserves and forwards HTTP headers from original requests
- **M3U8 Processing**: Modifies M3U8 playlists to redirect segments through local server
- **Segment Processing**: Serves video segments with automatic header detection
- **Health Check**: Provides health check endpoint
## How It Works
1. **Server Startup**: The NanoHTTPD server starts on a random port (or specified port) to avoid conflicts
2. **Header Extraction**: HTTP headers from incoming requests are extracted and preserved
3. **M3U8 Processing**: When an M3U8 URL is processed, it fetches the original content with preserved headers and modifies segment URLs to point to the local server
4. **Automatic Detection**: When serving segments, the server automatically detects fake headers (JPEG, PNG, GIF) and skips them to reveal the actual video content
5. **Header Forwarding**: Headers are forwarded to both M3U8 and segment requests for compatibility
## Endpoints
### GET /m3u8?url=<url>
Processes an M3U8 file and returns modified content with local segment URLs.
**Parameters:**
- `url`: URL of the M3U8 file to process
**Headers:** All HTTP headers from the request are preserved and forwarded
**Response:** Modified M3U8 content with local segment URLs
### GET /segment?url=<url>
Serves a video segment with automatic header detection.
**Parameters:**
- `url`: URL of the video segment
**Headers:** All HTTP headers from the request are preserved and forwarded
**Response:** Video segment data with fake headers automatically removed
### GET /health
Health check endpoint.
**Response:** Server status message
## Integration
The library provides the `M3u8Integration` class for easy integration:
```kotlin
// Initialize integration
val integration = M3u8Integration(client)
// Process video list
val processedVideos = integration.processVideoList(originalVideos)
// Get server info
println(integration.getServerInfo())
// Stop server when done
integration.stopServer()
```
## Automatic Detection
The server automatically detects various fake header formats:
- **JPEG Headers**: Detects JPEG magic bytes and finds video content after them
- **PNG Headers**: Detects PNG magic bytes and finds video content after them
- **GIF Headers**: Detects GIF magic bytes and finds video content after them
- **MPEG-TS**: Detects MPEG-TS sync bytes (0x47) for valid transport streams
- **MP4**: Detects "ftyp" atom for MP4 files
- **AVI**: Detects "RIFF" and "AVI" headers for AVI files
## Usage Example
```kotlin
// Create server manager
val manager = M3u8ServerManager()
// Start server with random port (recommended)
manager.startServer() // Uses random port by default
// Or start server on specific port
manager.startServer(8080)
// Process M3U8 URL
val originalUrl = "https://example.com/playlist.m3u8"
val processedUrl = runBlocking { manager.processM3u8Url(originalUrl) }
println("Original: $originalUrl")
println("Processed: $processedUrl")
println(manager.getServerInfo())
// Stop server
manager.stopServer()
```
## Dependencies
- **OkHttp**: HTTP client for fetching content
- **NanoHTTPD**: Lightweight HTTP server for Android
## Architecture
```
Extension/App
M3u8Integration
M3u8ServerManager
M3u8HttpServer (NanoHTTPD)
AutoDetector
```
The library uses NanoHTTPD to provide a real HTTP server that's compatible with Android and can handle M3U8 processing requests with header preservation.

View File

@@ -0,0 +1,7 @@
plugins {
alias(kei.plugins.library)
}
dependencies {
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:-SNAPSHOT")
}

View File

@@ -0,0 +1,163 @@
package aniyomi.lib.m3u8server
/**
* Automatic file format detector and offset calculator
*/
object AutoDetector {
// Magic headers for different formats
private val JPEG_HEADER = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
private val PNG_HEADER = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte())
private val GIF_HEADER = byteArrayOf(0x47.toByte(), 0x49.toByte(), 0x46.toByte())
private const val MPEG_TS_SYNC = 0x47.toByte()
private val MP4_FTYP = byteArrayOf(0x66.toByte(), 0x74.toByte(), 0x79.toByte(), 0x70.toByte()) // "ftyp"
private val AVI_RIFF = byteArrayOf(0x52.toByte(), 0x49.toByte(), 0x46.toByte(), 0x46.toByte()) // "RIFF"
private val AVI_AVI = byteArrayOf(0x41.toByte(), 0x56.toByte(), 0x49.toByte(), 0x20.toByte()) // "AVI "
private const val MPEG_TS_PACKET_SIZE = 188
/**
* Automatically detects how many bytes to skip at the beginning of the file
* @param data File data (first 4KB is sufficient)
* @return Number of bytes to skip
*/
fun detectSkipBytes(data: ByteArray): Int {
if (data.isEmpty()) return 0
return when {
// If it's already a valid MPEG-TS, don't need to skip anything
isMpegTsValid(data) -> 0
// If it's JPEG/PNG/GIF disguising another format
isJpegHeader(data) || isPngHeader(data) || isGifHeader(data) -> detectDisguise(data)
// If it's already a valid video format
isVideoFormat(data) -> 0
// Unrecognized pattern, don't skip anything
else -> 0
}
}
/**
* Checks if it's a valid MPEG-TS stream
*/
private fun isMpegTsValid(data: ByteArray): Boolean {
if (data.size < MPEG_TS_PACKET_SIZE) return false
// Check if the first byte is sync byte
if (data[0] != MPEG_TS_SYNC) return false
// Check if there are multiple sync bytes in correct locations
var validPackets = 0
for (i in 0 until minOf(data.size, 1024) step MPEG_TS_PACKET_SIZE) {
if (i + MPEG_TS_PACKET_SIZE <= data.size && data[i] == MPEG_TS_SYNC) {
validPackets++
}
}
return validPackets >= 3
}
/**
* Checks if it starts with JPEG header
*/
private fun isJpegHeader(data: ByteArray): Boolean {
if (data.size < 3) return false
return data[0] == JPEG_HEADER[0] &&
data[1] == JPEG_HEADER[1] &&
data[2] == JPEG_HEADER[2]
}
/**
* Checks if it starts with PNG header
*/
private fun isPngHeader(data: ByteArray): Boolean {
if (data.size < 4) return false
return data[0] == PNG_HEADER[0] &&
data[1] == PNG_HEADER[1] &&
data[2] == PNG_HEADER[2] &&
data[3] == PNG_HEADER[3]
}
/**
* Checks if it starts with GIF header
*/
private fun isGifHeader(data: ByteArray): Boolean {
if (data.size < 3) return false
return data[0] == GIF_HEADER[0] &&
data[1] == GIF_HEADER[1] &&
data[2] == GIF_HEADER[2]
}
/**
* Detects if a video is disguised under another format
*/
private fun detectDisguise(data: ByteArray): Int {
// Look for MP4 "ftyp" box
val ftypOffset = findPattern(data, MP4_FTYP)
if (ftypOffset >= 4) {
return ftypOffset - 4 // "ftyp" is preceded by 4 bytes of size
}
// Look for AVI "RIFF"
val riffOffset = findPattern(data, AVI_RIFF)
if (riffOffset > 0) {
return riffOffset
}
// Look for MPEG-TS sync byte
val mpegTsOffset = findMpegTsSync(data)
if (mpegTsOffset > 0) {
return mpegTsOffset
}
return 0
}
/**
* Checks if it's already a valid video format
*/
private fun isVideoFormat(data: ByteArray): Boolean = isMpegTsValid(data) ||
findPattern(data, MP4_FTYP) >= 0 ||
findPattern(data, AVI_RIFF) >= 0
/**
* Finds a specific pattern in the data
*/
private fun findPattern(data: ByteArray, pattern: ByteArray): Int {
for (i in 0..data.size - pattern.size) {
var found = true
for (j in pattern.indices) {
if (data[i + j] != pattern[j]) {
found = false
break
}
}
if (found) {
return i
}
}
return -1
}
/**
* Finds the first MPEG-TS sync byte
*/
private fun findMpegTsSync(data: ByteArray): Int {
for (i in data.indices) {
if (data[i] == MPEG_TS_SYNC) {
// Check if there's a pattern of sync bytes
var validCount = 0
for (j in i until minOf(data.size, i + 1024) step MPEG_TS_PACKET_SIZE) {
if (j + MPEG_TS_PACKET_SIZE <= data.size && data[j] == MPEG_TS_SYNC) {
validCount++
}
}
if (validCount >= 2) {
return i
}
}
}
return -1
}
}

View File

@@ -0,0 +1,313 @@
package aniyomi.lib.m3u8server
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import org.nanohttpd.protocols.http.IHTTPSession
import org.nanohttpd.protocols.http.NanoHTTPD
import org.nanohttpd.protocols.http.response.Response
import org.nanohttpd.protocols.http.response.Response.newChunkedResponse
import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
import org.nanohttpd.protocols.http.response.Status
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.URLEncoder
/**
* Real HTTP server for M3U8 processing using NanoHTTPD
* Compatible with Android and provides actual HTTP endpoints
*/
class M3u8HttpServer(
private val client: OkHttpClient,
port: Int = 0, // 0 means random port
) : NanoHTTPD(port) {
val port: Int
get() = super.getListeningPort()
private val tag by lazy { javaClass.simpleName }
@Volatile
private var isRunning = false
override fun start() {
try {
super.start()
isRunning = true
Log.d(tag, "M3U8 HTTP Server started on port $port")
} catch (e: Exception) {
Log.e(tag, "Failed to start server: ${e.message}")
throw e
}
}
override fun stop() {
super.stop()
isRunning = false
Log.d(tag, "M3U8 HTTP Server stopped")
}
fun isRunning(): Boolean = isRunning
override fun handle(session: IHTTPSession): Response {
val uri = session.uri
val method = session.method
Log.d(tag, "Received request: $method $uri from ${session.remoteIpAddress}")
val response = when {
uri.startsWith("/m3u8") -> handleM3u8Request(session)
uri.startsWith("/segment") -> handleSegmentRequest(session)
uri.startsWith("/health") -> handleHealthRequest()
else -> {
Log.w(tag, "Unknown endpoint: $uri")
newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found")
}
}
Log.d(tag, "Response status: ${response.status}")
return response
}
private fun handleM3u8Request(session: IHTTPSession): Response {
val url = session.parameters["url"]?.first()
val headers = extractHeadersFromSession(session)
Log.d(tag, "Processing M3U8 request for URL: $url")
Log.d(tag, "Headers: $headers")
if (url.isNullOrBlank()) {
Log.w(tag, "Missing URL parameter in M3U8 request")
return newFixedLengthResponse(Status.BAD_REQUEST, MIME_PLAINTEXT, "Missing url parameter")
}
return try {
Log.d(tag, "Starting M3U8 processing for: $url")
val processedContent = runBlocking { processM3u8Content(url, headers) }
Log.d(tag, "M3U8 processing completed successfully, content length: ${processedContent.length}")
newFixedLengthResponse(Status.OK, "application/vnd.apple.mpegurl", processedContent)
} catch (e: Exception) {
Log.e(tag, "Error processing M3U8: ${e.message}", e)
newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Error: ${e.message}")
}
}
private fun handleSegmentRequest(session: IHTTPSession): Response {
val url = session.parameters["url"]?.first()
val headers = extractHeadersFromSession(session)
Log.d(tag, "Processing segment request for URL: $url")
Log.d(tag, "Headers: $headers")
if (url.isNullOrBlank()) {
Log.w(tag, "Missing URL parameter in segment request")
return newFixedLengthResponse(Status.BAD_REQUEST, MIME_PLAINTEXT, "Missing url parameter")
}
return try {
Log.d(tag, "Starting segment processing for: $url")
val segmentData = runBlocking { processSegmentUrl(url, headers) }
Log.d(tag, "Segment processing completed successfully, data size: ${segmentData.size} bytes")
val inputStream = ByteArrayInputStream(segmentData)
newChunkedResponse(Status.OK, "video/mp2t", inputStream)
} catch (e: Exception) {
Log.e(tag, "Error processing segment: ${e.message}", e)
newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Error: ${e.message}")
}
}
private fun handleHealthRequest(): Response {
Log.d(tag, "Health check requested")
val status = getHealthStatus()
Log.d(tag, "Health status: $status")
return newFixedLengthResponse(Status.OK, MIME_PLAINTEXT, status)
}
/**
* Extract headers from the HTTP session
*/
private fun extractHeadersFromSession(session: IHTTPSession): Map<String, String> {
val headers = mutableMapOf<String, String>()
// Extract common headers that might be needed for video requests
session.headers.forEach { (key, value) ->
when (key.lowercase()) {
"user-agent", "referer", "origin", "accept", "accept-language",
"accept-encoding", "connection", "cache-control", "pragma",
-> {
headers[key] = value
}
}
}
Log.d(tag, "Extracted headers: $headers")
return headers
}
/**
* Process M3U8 content through the server
*/
private suspend fun processM3u8Content(url: String, headers: Map<String, String> = emptyMap()): String = withContext(Dispatchers.IO) {
try {
Log.d(tag, "Fetching M3U8 content from: $url with headers: $headers")
val m3u8Content = fetchM3u8Content(url, headers)
Log.d(tag, "Original M3U8 content length: ${m3u8Content.length}")
val modifiedContent = modifyM3u8Content(m3u8Content, url, port)
Log.d(tag, "Modified M3U8 content length: ${modifiedContent.length}")
Log.d(tag, "M3U8 processing completed successfully")
modifiedContent
} catch (e: Exception) {
Log.e(tag, "Error processing M3U8 URL: ${e.message}", e)
throw IOException("Error processing m3u8: ${e.message}")
}
}
/**
* Process segment with automatic detection
*/
suspend fun processSegmentUrl(url: String, headers: Map<String, String> = emptyMap()): ByteArray = withContext(Dispatchers.IO) {
try {
Log.d(tag, "Fetching segment from: $url with headers: $headers")
val segmentData = fetchSegmentWithAutoDetection(url, headers)
Log.d(tag, "Segment processing completed, final size: ${segmentData.size} bytes")
segmentData
} catch (e: Exception) {
Log.e(tag, "Error processing segment URL: ${e.message}", e)
throw IOException("Error processing segment: ${e.message}")
}
}
/**
* Health check
*/
fun getHealthStatus(): String = if (isRunning) {
"M3U8 HTTP Server is running on port $port"
} else {
"M3U8 HTTP Server is not running"
}
private suspend fun fetchM3u8Content(url: String, headers: Map<String, String> = emptyMap()): String = withContext(Dispatchers.IO) {
Log.d(tag, "Making HTTP request to fetch M3U8 content with headers: $headers")
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
Log.d(tag, "M3U8 HTTP response code: ${response.code}")
if (!response.isSuccessful) {
Log.e(tag, "Failed to fetch M3U8 content, HTTP code: ${response.code}")
throw IOException("Failed to fetch m3u8: ${response.code}")
}
val content = response.body.string()
if (content.isBlank()) {
Log.e(tag, "Empty M3U8 response body")
throw IOException("Empty response body")
}
Log.d(tag, "Successfully fetched M3U8 content")
content
}
}
private suspend fun fetchSegmentWithAutoDetection(url: String, headers: Map<String, String> = emptyMap()): ByteArray = withContext(Dispatchers.IO) {
Log.d(tag, "Making HTTP request to fetch segment with headers: $headers")
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
Log.d(tag, "Segment HTTP response code: ${response.code}")
if (!response.isSuccessful) {
Log.e(tag, "Failed to fetch segment, HTTP code: ${response.code}")
throw IOException("Failed to fetch segment: ${response.code}")
}
val inputStream = response.body.byteStream()
val outputStream = ByteArrayOutputStream()
// Read first 4KB to detect format
val buffer = ByteArray(4096)
val bytesRead = inputStream.read(buffer)
Log.d(tag, "Read $bytesRead bytes from segment for format detection")
if (bytesRead > 0) {
val skipBytes = AutoDetector.detectSkipBytes(buffer.copyOf(bytesRead))
Log.d(tag, "AutoDetector determined skip bytes: $skipBytes")
// Write data from detected offset
val validBytes = bytesRead - skipBytes
outputStream.write(buffer, skipBytes, validBytes)
Log.d(tag, "Wrote $validBytes bytes from detected offset")
// Copy remaining data
val remainingBytes = inputStream.copyTo(outputStream)
Log.d(tag, "Copied $remainingBytes remaining bytes")
}
inputStream.close()
val finalData = outputStream.toByteArray()
outputStream.close()
Log.d(tag, "Final segment data size: ${finalData.size} bytes")
finalData
}
}
/**
* Creates a local M3U8 URL by encoding the original URL and redirecting to the local server.
* It can either be segment URL or a direct M3U8 URL (not a playlist).
*/
fun createLocalUrl(m3u8Url: String): String {
val encodedUrl = URLEncoder.encode(m3u8Url, Charsets.UTF_8.name())
return "http://localhost:$port/m3u8?url=$encodedUrl"
}
private fun modifyM3u8Content(content: String, originalUrl: String, serverPort: Int): String {
Log.d(tag, "Modifying M3U8 content for server port: $serverPort")
val lines = content.lines().toMutableList()
val modifiedLines = mutableListOf<String>()
var segmentCount = 0
// Determine base URL from the original URL
val baseHttpUrl = originalUrl.toHttpUrlOrNull()
for (line in lines) {
when {
line.startsWith("#") -> {
// Keep comments and headers
modifiedLines.add(line)
}
line.isNotBlank() && !line.startsWith("#") -> {
// This is a segment URL, resolve against base URL
val resolvedUrl = baseHttpUrl?.resolve(line)?.toString() ?: line
val encodedUrl = URLEncoder.encode(resolvedUrl, Charsets.UTF_8.name())
val localUrl = "http://localhost:$serverPort/segment?url=$encodedUrl"
modifiedLines.add(localUrl)
segmentCount++
}
else -> {
// Keep empty lines
modifiedLines.add(line)
}
}
}
Log.d(tag, "Modified M3U8 content: $segmentCount segments redirected to local server")
return modifiedLines.joinToString("\n")
}
}

View File

@@ -0,0 +1,92 @@
package aniyomi.lib.m3u8server
import android.util.Log
import eu.kanade.tachiyomi.animesource.model.Video
import okhttp3.OkHttpClient
/**
* M3U8 Server integration with Q1N extension
*/
class M3u8Integration(
client: OkHttpClient,
private val serverManager: M3u8ServerManager = M3u8ServerManager(client),
) {
private val tag by lazy { javaClass.simpleName }
private fun initializeServer() {
if (!serverManager.isRunning()) {
try {
serverManager.startServer() // Uses random port by default
Log.d(tag, "M3U8 server initialized on port: ${serverManager.getServerUrl()}")
} catch (e: Exception) {
// Log error but don't crash
Log.e(tag, "Failed to start M3U8 server: ${e.message}")
}
}
}
/**
* Processes an M3U8 video through the local server
* @param originalVideo Original video with M3U8 URL
* @return Processed video with local URL
*/
private fun processM3u8Video(originalVideo: Video): Video {
val processedUrl = serverManager.processM3u8Url(originalVideo.url)
return Video(
videoUrl = processedUrl ?: originalVideo.url,
url = originalVideo.url,
quality = originalVideo.quality,
subtitleTracks = originalVideo.subtitleTracks,
audioTracks = originalVideo.audioTracks,
headers = originalVideo.headers,
)
}
/**
* Processes a list of videos, identifying and processing only M3U8 files.
* The M3U8 files should be a direct link to the M3U8 file which consists of segments, not a playlist.
* @param videos Original video list
* @return Processed video list
*/
fun processVideoList(videos: List<Video>): List<Video> {
initializeServer()
return videos.map { video ->
if (isM3u8Url(video.url)) {
processM3u8Video(video)
} else {
video
}
}
}
/**
* Checks if a URL is an M3U8 file
* @param url URL to check
* @return true if it's an M3U8
*/
private fun isM3u8Url(url: String): Boolean {
val m3u8Regex = Regex("""\.m3u8($|\?|#)""", RegexOption.IGNORE_CASE)
return m3u8Regex.containsMatchIn(url) ||
url.contains("application/vnd.apple.mpegurl", ignoreCase = true)
}
/**
* Gets server information
* @return String with server information
*/
fun getServerInfo(): String = serverManager.getServerInfo()
/**
* Stops the server
*/
fun stopServer() {
serverManager.stopServer()
}
/**
* Checks if the server is running
* @return true if it's running
*/
fun isServerRunning(): Boolean = serverManager.isRunning()
}

View File

@@ -0,0 +1,85 @@
package aniyomi.lib.m3u8server
import android.util.Log
import okhttp3.OkHttpClient
/**
* M3U8 Server manager to facilitate usage
*/
class M3u8ServerManager(
private val client: OkHttpClient,
) {
private val tag by lazy { javaClass.simpleName }
private var server: M3u8HttpServer? = null
/**
* Starts the M3U8 server on the specified port
* @param port Port where the server will run (0 for random port, default: 0)
*/
@Synchronized
fun startServer(port: Int = 0) {
if (server != null) {
Log.d(tag, "Server is already running")
return
}
try {
server = M3u8HttpServer(client, port)
server?.start()
Log.d(tag, "Server started on port: ${server?.port}")
} catch (e: Exception) {
Log.e(tag, "Failed to start server: ${e.message}")
server = null
throw e
}
}
/**
* Stops the M3U8 server
*/
@Synchronized
fun stopServer() {
server?.stop()
server = null
Log.d(tag, "M3U8 HTTP Server stopped")
}
/**
* Checks if the server is running
*/
fun isRunning(): Boolean = server?.isRunning() ?: false
/**
* Gets the server base URL
*/
fun getServerUrl(): String? = server?.let { "http://localhost:${it.port}" }
/**
* Processes an M3U8 file through the server
* @param m3u8Url Original M3U8 file URL
* @return Processed M3U8 content
*/
fun processM3u8Url(m3u8Url: String): String? = server?.createLocalUrl(m3u8Url)
/**
* Processes a segment through the server
* @param segmentUrl Original segment URL
* @param headers Optional headers to use for the request
* @return Processed segment data
*/
suspend fun processSegmentUrl(segmentUrl: String, headers: Map<String, String> = emptyMap()): ByteArray? = server?.processSegmentUrl(segmentUrl, headers)
/**
* Gets server information
*/
fun getServerInfo(): String = if (isRunning()) {
val serverUrl = getServerUrl() ?: "Unknown"
"""
M3U8 HTTP Server is running
Base URL: $serverUrl
Status: ${server?.getHealthStatus()}
""".trimIndent()
} else {
"M3U8 HTTP Server is not running"
}
}

View File

@@ -3,5 +3,6 @@ plugins {
}
dependencies {
implementation(project(":lib:m3u8server"))
implementation(project(":lib:playlistutils"))
}

View File

@@ -1,6 +1,7 @@
package aniyomi.lib.megacloudextractor
import android.util.Log
import aniyomi.lib.m3u8server.M3u8Integration
import aniyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
@@ -24,14 +25,26 @@ class MegaCloudExtractor(
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val m3u8Integration by lazy { M3u8Integration(client) }
companion object {
private const val SOURCES_URL = "/embed-2/v3/e-1/getSources?id="
private const val SOURCES_SPLITTER = "/e-1/"
}
fun getVideosFromUrl(url: String, type: String, name: String): List<Video> {
val videos = getVideoDto(url)
fun getVideosFromUrl(
url: String,
type: String,
name: String,
withM3u8Server: Boolean = false,
): List<Video> {
val host = runCatching {
url.toHttpUrl().host
}.getOrNull() ?: throw IllegalStateException("MegaCloud host is invalid: $url")
val megaCloudServerUrl = "https://$host"
val videos = getVideoDto(url, megaCloudServerUrl)
if (videos.isEmpty()) return emptyList()
val subtitles = videos.first().tracks
@@ -47,20 +60,20 @@ class MegaCloudExtractor(
subtitleList = subtitles,
referer = "https://${url.toHttpUrl().host}/",
)
}.let {
if (withM3u8Server) {
m3u8Integration.processVideoList(it)
} else {
it
}
}
}
private fun getVideoDto(url: String): List<VideoDto> {
private fun getVideoDto(url: String, megaCloudServerUrl: String): List<VideoDto> {
val id = url.substringAfter(SOURCES_SPLITTER, "")
.substringBefore("?", "")
.ifEmpty { throw Exception("Failed to extract ID from URL") }
val host = runCatching {
url.toHttpUrl().host
}.getOrNull() ?: throw IllegalStateException("MegaCloud host is invalid: $url")
val megaCloudServerUrl = "https://$host"
val megaCloudHeaders = headers.newBuilder()
.add("Accept", "*/*")
.add("X-Requested-With", "XMLHttpRequest")

View File

@@ -9,5 +9,5 @@ ext {
apply plugin: "kei.plugins.extension.legacy"
dependencies {
api(project(":lib:megacloudextractor"))
implementation(project(":lib:megacloudextractor"))
}

View File

@@ -31,7 +31,7 @@ class AniWatchtv :
url = url.substringBefore("?")
}
override fun extractVideo(server: VideoData): List<Video> = when (server.name) {
override suspend fun extractVideo(server: VideoData): List<Video> = when (server.name) {
"VidSrc", "MegaCloud" -> megaCloudExtractor.getVideosFromUrl(server.link, server.type, server.name)
else -> emptyList()
}

View File

@@ -9,5 +9,5 @@ ext {
apply plugin: "kei.plugins.extension.legacy"
dependencies {
api(project(":lib:rapidcloudextractor"))
implementation(project(":lib:rapidcloudextractor"))
}

View File

@@ -17,7 +17,7 @@ class Kaido :
) {
private val rapidCloudExtractor by lazy { RapidCloudExtractor(client, headers, preferences) }
override fun extractVideo(server: VideoData): List<Video> = when (server.name) {
override suspend fun extractVideo(server: VideoData): List<Video> = when (server.name) {
"Vidstreaming", "Vidcloud" -> rapidCloudExtractor.getVideosFromUrl(server.link, server.type, server.name)
else -> emptyList()
}

View File

@@ -9,5 +9,5 @@ ext {
apply plugin: "kei.plugins.extension.legacy"
dependencies {
api(project(":lib:rapidcloudextractor"))
implementation(project(":lib:rapidcloudextractor"))
}

View File

@@ -91,7 +91,7 @@ class NineAnimeTv :
private val rapidCloudExtractor by lazy { RapidCloudExtractor(client, headers, preferences) }
override fun extractVideo(server: VideoData): List<Video> = when (server.name) {
override suspend fun extractVideo(server: VideoData): List<Video> = when (server.name) {
"DouVideo", "Vidstreaming", "Vidcloud" -> rapidCloudExtractor.getVideosFromUrl(server.link, server.type, server.name)
else -> emptyList()
}

View File

@@ -9,6 +9,6 @@ ext {
apply plugin: "kei.plugins.extension.legacy"
dependencies {
api(project(":lib:megacloudextractor"))
api(project(":lib:streamtapeextractor"))
implementation(project(":lib:megacloudextractor"))
implementation(project(":lib:streamtapeextractor"))
}

View File

@@ -48,7 +48,7 @@ class HiAnime :
url = url.substringBefore("?")
}
override fun extractVideo(server: VideoData): List<Video> = when (server.name) {
override suspend fun extractVideo(server: VideoData): List<Video> = when (server.name) {
"StreamTape" -> {
streamtapeExtractor.videoFromUrl(
server.link,
@@ -60,6 +60,7 @@ class HiAnime :
server.link,
server.type,
server.name,
server.name == "HD-3",
)
else -> emptyList()