mirror of
https://github.com/yuzono/anime-extensions.git
synced 2026-06-13 13:39:44 +00:00
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:
@@ -7,5 +7,5 @@ plugins {
|
||||
baseVersionCode = 4
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:megaupextractor"))
|
||||
api(project(":lib:megaupextractor"))
|
||||
}
|
||||
|
||||
@@ -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
122
lib/m3u8server/README.md
Normal 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.
|
||||
7
lib/m3u8server/build.gradle.kts
Normal file
7
lib/m3u8server/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
alias(kei.plugins.library)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:-SNAPSHOT")
|
||||
}
|
||||
163
lib/m3u8server/src/aniyomi/lib/m3u8server/AutoDetector.kt
Normal file
163
lib/m3u8server/src/aniyomi/lib/m3u8server/AutoDetector.kt
Normal 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
|
||||
}
|
||||
}
|
||||
313
lib/m3u8server/src/aniyomi/lib/m3u8server/M3u8HttpServer.kt
Normal file
313
lib/m3u8server/src/aniyomi/lib/m3u8server/M3u8HttpServer.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
92
lib/m3u8server/src/aniyomi/lib/m3u8server/M3u8Integration.kt
Normal file
92
lib/m3u8server/src/aniyomi/lib/m3u8server/M3u8Integration.kt
Normal 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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:m3u8server"))
|
||||
implementation(project(":lib:playlistutils"))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,5 +9,5 @@ ext {
|
||||
apply plugin: "kei.plugins.extension.legacy"
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:megacloudextractor"))
|
||||
implementation(project(":lib:megacloudextractor"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ ext {
|
||||
apply plugin: "kei.plugins.extension.legacy"
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:rapidcloudextractor"))
|
||||
implementation(project(":lib:rapidcloudextractor"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ ext {
|
||||
apply plugin: "kei.plugins.extension.legacy"
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:rapidcloudextractor"))
|
||||
implementation(project(":lib:rapidcloudextractor"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user