diff --git a/src/main/kotlin/com/abmo/Application.kt b/src/main/kotlin/com/abmo/Application.kt new file mode 100644 index 0000000..78af3f4 --- /dev/null +++ b/src/main/kotlin/com/abmo/Application.kt @@ -0,0 +1,89 @@ +package com.abmo + +import com.abmo.common.Constants +import com.abmo.common.Logger +import com.abmo.model.Config +import com.abmo.services.ProviderDispatcher +import com.abmo.services.VideoDownloader +import com.abmo.util.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf +import java.io.File +import kotlin.system.exitProcess + +class Application(private val args: Array): KoinComponent { + + private val videoDownloader: VideoDownloader by inject() + private val providerDispatcher: ProviderDispatcher by inject() + private val cliArguments: CliArguments by inject { parametersOf(args) } + + suspend fun run() { + + val outputFileName = cliArguments.getOutputFileName() + val headers = cliArguments.getHeaders() + val numberOfConnections = cliArguments.getParallelConnections() + Constants.VERBOSE = cliArguments.isVerboseEnabled() + + if (outputFileName != null && !isValidPath(outputFileName)) { + exitProcess(0) + } + + val scanner = java.util.Scanner(System.`in`) + + + try { + println("Enter the video URL or ID (e.g., K8R6OOjS7):") + val videoUrl = scanner.nextLine() + + val dispatcher = providerDispatcher.getProviderForUrl(videoUrl) + val videoID = dispatcher.getVideoID(videoUrl) + + val defaultHeader = if (videoUrl.isValidUrl()) { + mapOf("Referer" to videoUrl?.extractReferer()) + } else { emptyMap() } + + val url = "https://abysscdn.com/?v=$videoID" + val videoMetadata = videoDownloader.getVideoMetaData(url, headers ?: defaultHeader) + + val videoSources = videoMetadata?.sources + ?.sortedBy { it?.label?.filter { char -> char.isDigit() }?.toIntOrNull() } + + if (videoSources == null) { + Logger.error("Video with ID $videoID not found") + exitProcess(0) + } + + // For some reason ANSI applies to rest of text in the terminal starting from here + // I'm not sure what causes that, so I removed all error logger here, and it still occurs + // the only solution for now is to reset ANSI before displaying the message + println("${Logger.RESET}Choose the resolution you want to download:") + videoSources + .forEachIndexed { index, video -> + println("${index + 1}] ${video?.label} - ${video?.size.formatBytes()}") + } + + val choice = scanner.nextInt() + val resolution = videoSources[choice - 1]?.label + + + if (resolution != null) { + + val defaultFileName = "${url.getParameter("v")}_${resolution}_${System.currentTimeMillis()}.mp4" + val outputFile = outputFileName?.let { File(it) } ?: run { + Logger.warn("No output file specified. The video will be saved to the current directory as '$defaultFileName'.\n") + File(".", defaultFileName) // Default directory and name for saving video + } + + val config = Config(url, resolution, outputFile, headers, numberOfConnections) + Logger.info("video with id $videoID and resolution $resolution being processed...\n") + videoDownloader.downloadSegmentsInParallel(config, videoMetadata) + } + } catch (e: NoSuchElementException) { + println("\nCtrl + C detected. Exiting...") + } + + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/CliArguments.kt b/src/main/kotlin/com/abmo/CliArguments.kt index 6e0890f..106213b 100644 --- a/src/main/kotlin/com/abmo/CliArguments.kt +++ b/src/main/kotlin/com/abmo/CliArguments.kt @@ -1,37 +1,43 @@ package com.abmo import com.abmo.common.Constants.DEFAULT_CONCURRENT_DOWNLOAD_LIMIT +import com.abmo.common.Logger +/** + * A class for parsing command-line arguments. + * + * @param args The array of command-line arguments. + */ class CliArguments(private val args: Array) { + /** + * Extracts headers from command-line arguments in the format "--header key:value". + * + * @return A map of header names to their values, or null if no headers are found. + */ fun getHeaders(): Map? { val headers = mutableMapOf() - var i = 0 - - while (i < args.size) { - when (args[i]) { - "--header", "-H" -> { - if (i + 1 < args.size) { - val header = args[i + 1] - val parts = header.split(":", limit = 2) - if (parts.size == 2) { - val key = parts[0].trim() - val value = parts[1].trim() - headers[key] = value - } else { - println("Invalid header format. Use 'Header-Name: Header-Value'") - } - i += 1 - } + + for (i in args.indices) { + if (args[i] in arrayOf("--header", "-H") && i + 1 < args.size) { + val (key, value) = args[i + 1].split(":", limit = 2).map { it.trim() } + if (key.isNotEmpty() && value.isNotEmpty()) { + headers[key] = value + } else { + Logger.error("Invalid header format. Use 'Header-Name: Header-Value'") } } - i += 1 } return headers.ifEmpty { null } } - fun getOutputFileName(args: Array): String? { + /** + * Retrieves the output file name from command-line arguments. + * + * @return The output file path as a String, or null if not specified. + */ + fun getOutputFileName(): String? { val index = args.indexOf("-o") if (index != -1 && index + 1 < args.size) { val filePath = args[index + 1] @@ -40,6 +46,12 @@ class CliArguments(private val args: Array) { return null } + /** + * Retrieves the number of parallel connections from command-line arguments. + * + * @return The number of connections, constrained between 1 and 10. + * Returns the default value if not specified. + */ fun getParallelConnections(): Int { val maxConnections = 10 val minConnections = 1 @@ -54,4 +66,6 @@ class CliArguments(private val args: Array) { return DEFAULT_CONCURRENT_DOWNLOAD_LIMIT } + fun isVerboseEnabled() = args.contains("--verbose") + } \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/Main.kt b/src/main/kotlin/com/abmo/Main.kt index 349c4e0..d8f9457 100644 --- a/src/main/kotlin/com/abmo/Main.kt +++ b/src/main/kotlin/com/abmo/Main.kt @@ -1,88 +1,10 @@ package com.abmo -import com.abmo.common.Constants -import com.abmo.common.Logger -import com.abmo.model.Config -import com.abmo.services.ProviderDispatcher -import com.abmo.services.VideoDownloader -import com.abmo.crypto.CryptoHelper -import com.abmo.executor.JavaScriptExecutor -import com.abmo.util.* -import java.io.File -import kotlin.system.exitProcess +import com.abmo.di.koinModule +import org.koin.core.context.startKoin suspend fun main(args: Array) { - - val javaScriptExecutor = JavaScriptExecutor() - val cryptoHelper = CryptoHelper(javaScriptExecutor) - val videoDownloader = VideoDownloader(cryptoHelper) - val cliArguments = CliArguments(args) - val providerDispatcher = ProviderDispatcher(javaScriptExecutor) - - - val outputFileName = cliArguments.getOutputFileName(args) - val headers = cliArguments.getHeaders() - val numberOfConnections = cliArguments.getParallelConnections() - Constants.VERBOSE = args.contains("--verbose") - - if (outputFileName != null && !isValidPath(outputFileName)) { - exitProcess(0) - } - - val scanner = java.util.Scanner(System.`in`) - - - try { - println("Enter the video URL or ID (e.g., K8R6OOjS7):") - val videoUrl = scanner.nextLine() - - val dispatcher = providerDispatcher.getProviderForUrl(videoUrl) - val videoID = dispatcher.getVideoID(videoUrl) - - val defaultHeader = if (videoUrl.isValidUrl()) { - mapOf("Referer" to videoUrl?.extractReferer()) - } else { emptyMap() } - - val url = "https://abysscdn.com/?v=$videoID" - val videoMetadata = videoDownloader.getVideoMetaData(url, headers ?: defaultHeader) - - val videoSources = videoMetadata?.sources - ?.sortedBy { it?.label?.filter { char -> char.isDigit() }?.toIntOrNull() } - - if (videoSources == null) { - Logger.error("Video with ID $videoID not found") - exitProcess(0) - } - - // For some reason ANSI applies to rest of text in the terminal starting from here - // I'm not sure what causes that, so I removed all error logger here, and it still occurs - // the only solution for now is to reset ANSI before displaying the message - println("${Logger.RESET}Choose the resolution you want to download:") - videoSources - .forEachIndexed { index, video -> - println("${index + 1}] ${video?.label} - ${formatBytes(video?.size)}") - } - - val choice = scanner.nextInt() - val resolution = videoSources[choice - 1]?.label - - - if (resolution != null) { - - val defaultFileName = "${url.getParameter("v")}_${resolution}_${System.currentTimeMillis()}.mp4" - val outputFile = outputFileName?.let { File(it) } ?: run { - Logger.warn("No output file specified. The video will be saved to the current directory as '$defaultFileName'.\n") - File(".", defaultFileName) // Default directory and name for saving video - } - - val config = Config(url, resolution, outputFile, headers, numberOfConnections) - Logger.info("video with id $videoID and resolution $resolution being processed...\n") - videoDownloader.downloadSegmentsInParallel(config, videoMetadata) - } - } catch (e: NoSuchElementException) { - println("\nCtrl + C detected. Exiting...") - } - - + startKoin { modules(koinModule) } + Application(args).run() } diff --git a/src/main/kotlin/com/abmo/common/Constants.kt b/src/main/kotlin/com/abmo/common/Constants.kt index 8699621..feb5ede 100644 --- a/src/main/kotlin/com/abmo/common/Constants.kt +++ b/src/main/kotlin/com/abmo/common/Constants.kt @@ -2,7 +2,15 @@ package com.abmo.common object Constants { + /** + * The default maximum number of concurrent downloads allowed. + */ const val DEFAULT_CONCURRENT_DOWNLOAD_LIMIT = 4 - var VERBOSE = false // Set to true for verbose logging, false to disable + + /** + * Toggle for verbose logging. + * Set to `true` to enable detailed logs, `false` to disable. + */ + var VERBOSE = false } \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/common/Logger.kt b/src/main/kotlin/com/abmo/common/Logger.kt index f01a127..ae2bee3 100644 --- a/src/main/kotlin/com/abmo/common/Logger.kt +++ b/src/main/kotlin/com/abmo/common/Logger.kt @@ -12,7 +12,13 @@ object Logger { private const val CYAN = "\u001B[36m" private const val GREEN = "\u001B[32m" - // Enable ANSI support on Windows CMD (for Windows 10+) + /** + * Enables ANSI color support on Windows Command Prompt (CMD) for Windows 10 and above. + * + * Checks if the operating system is Windows and, if so, attempts to enable ANSI escape + * code support for colored text output in CMD. This is done by running a command in CMD + * if a console is available. + */ private fun enableAnsiOnWindows() { if (System.getProperty("os.name").contains("Windows")) { try { @@ -25,22 +31,50 @@ object Logger { } } + /** + * Wraps the given text with an ANSI color code, applying the specified color. + * + * @param text The text to be colorized. + * @param colorCode The ANSI color code to apply to the text. + * @return The colorized text, with the color code prepended and a reset code appended. + */ private fun colorize( text: String, colorCode: String): String { return "$colorCode$text$RESET" } + /** + * Prints an informational message in cyan. + * + * @param message The message to be printed as an informational log. + */ fun info(message: String) { println(colorize("INFO: $message", CYAN)) } + /** + * Prints a warning message in yellow. + * + * @param message The message to be printed as a warning log. + */ fun warn(message: String) { println(colorize("WARN: $message", YELLOW)) } + /** + * Prints an error message in red. + * + * @param message The message to be printed as an error log. + */ fun error(message: String) { println(colorize("ERROR: $message", RED)) } + /** + * Prints a debug message, color-coded based on whether it's an error or not, if verbose mode is enabled. + * + * @param message The message to be printed as a debug log. + * @param isError Indicates whether the debug message represents an error (applies red color if true). + */ fun debug(message: String, isError: Boolean = false) { if (Constants.VERBOSE) { val debugTextColor = if (isError) { RED } else { PURPLE } @@ -48,6 +82,11 @@ object Logger { } } + /** + * Prints a success message in green. + * + * @param message The message to be printed as a success log. + */ fun success(message: String) { println(colorize("SUCCESS: $message", GREEN)) } diff --git a/src/main/kotlin/com/abmo/crypto/CryptoHelper.kt b/src/main/kotlin/com/abmo/crypto/CryptoHelper.kt index efa4e2a..c522cb8 100644 --- a/src/main/kotlin/com/abmo/crypto/CryptoHelper.kt +++ b/src/main/kotlin/com/abmo/crypto/CryptoHelper.kt @@ -5,15 +5,24 @@ import com.abmo.executor.JavaScriptExecutor import com.google.gson.Gson import com.abmo.model.Video import com.google.gson.JsonSyntaxException +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.nio.charset.StandardCharsets import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec -class CryptoHelper( - private val javaScriptExecutor: JavaScriptExecutor -) { +class CryptoHelper : KoinComponent { + private val javaScriptExecutor: JavaScriptExecutor by inject() + private val gson: Gson by inject() + + /** + * Decrypts and decodes an encrypted string into a `Video` object. + * + * @param encryptedInput The encrypted input string to decode and decrypt. + * @return The decoded `Video` object, or null if decryption or deserialization fails. + */ fun decodeEncryptedString(encryptedInput: String?): Video? { Logger.debug("Starting decryption and decoding of the encrypted response.") if (encryptedInput != null) { @@ -38,7 +47,7 @@ class CryptoHelper( Logger.debug("Decryption successful. Decrypted data (truncated): ${decodedString.take(100)}...") return try { Logger.debug("Deserializing JSON string to Video object.") - Gson().fromJson(decodeUtf8String(decodedString), Video::class.java) + gson.fromJson(decodeUtf8String(decodedString), Video::class.java) } catch (e: JsonSyntaxException) { Logger.error("Failed to deserialize JSON to Video object: ${e.message}") null @@ -71,6 +80,14 @@ class CryptoHelper( } + /** + * Initializes a Cipher object for AES encryption or decryption. + * + * @param mode The operation mode (Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE). + * @param key The secret key used for the cipher. It must be 16 bytes long (128 bits) for AES. + * @return The initialized Cipher object. + * @throws Exception If an error occurs during cipher initialization. + */ private fun initCipher(mode: Int, key: String): Cipher { val keyBytes = key.toByteArray(StandardCharsets.UTF_8) val iv = keyBytes.sliceArray(0 until 16) @@ -81,6 +98,14 @@ class CryptoHelper( return cipher } + /** + * Encrypts the given data using AES in CTR mode. + * + * @param data The plaintext data to be encrypted. If null, encryption will not be performed. + * @param key The secret key used for encryption. It must be 16 bytes long (128 bits) for AES. + * @return The encrypted data as a string encoded in ISO-8859-1. + * @throws Exception If an error occurs during encryption. + */ fun encryptAESCTR(data: String?, key: String): String { val cipher = initCipher(Cipher.ENCRYPT_MODE, key) val dataBytes = data?.toByteArray(StandardCharsets.UTF_8) @@ -88,7 +113,14 @@ class CryptoHelper( return String(encryptedBytes, Charsets.ISO_8859_1) } - + /** + * Decrypts the given byte array using AES in CTR mode. + * + * @param data The encrypted data as a byte array to be decrypted. + * @param key The secret key used for decryption. It must be 16 bytes long (128 bits) for AES. + * @return The decrypted plaintext data as a byte array. + * @throws Exception If an error occurs during decryption. + */ fun decryptAESCTR(data: ByteArray, key: String): ByteArray { val cipher = initCipher(Cipher.DECRYPT_MODE, key) return cipher.doFinal(data) diff --git a/src/main/kotlin/com/abmo/di/KoinModule.kt b/src/main/kotlin/com/abmo/di/KoinModule.kt new file mode 100644 index 0000000..3923591 --- /dev/null +++ b/src/main/kotlin/com/abmo/di/KoinModule.kt @@ -0,0 +1,18 @@ +package com.abmo.di + +import com.abmo.CliArguments +import com.abmo.crypto.CryptoHelper +import com.abmo.executor.JavaScriptExecutor +import com.abmo.services.ProviderDispatcher +import com.abmo.services.VideoDownloader +import com.google.gson.Gson +import org.koin.dsl.module + +val koinModule = module { + single { JavaScriptExecutor() } + single { CryptoHelper() } + single { VideoDownloader() } + single { ProviderDispatcher() } + single { Gson() } + factory { (args: Array) -> CliArguments(args) } +} \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/executor/JavaScriptExecutor.kt b/src/main/kotlin/com/abmo/executor/JavaScriptExecutor.kt index 1d2b0f6..4a6592d 100644 --- a/src/main/kotlin/com/abmo/executor/JavaScriptExecutor.kt +++ b/src/main/kotlin/com/abmo/executor/JavaScriptExecutor.kt @@ -9,22 +9,31 @@ class JavaScriptExecutor { private val context = Context.enter() + /** + * Executes a JavaScript function from a file with the specified arguments. + * + * @param fileName The name of the JavaScript file to load from resources. + * @param identifier The name of the JavaScript function to call. + * @param arguments The arguments to pass to the JavaScript function. + * @return The result of the JavaScript function as a String, or an empty string if the function is not found or result is not a String. + * @throws IllegalArgumentException If the JavaScript file is not found in resources. + */ fun runJavaScriptCode(fileName: String, identifier: String, vararg arguments: Any?): String { - val scope: Scriptable = context.initStandardObjects() - val jsFileStream: InputStream = javaClass.getResourceAsStream("/$fileName") - ?: throw IllegalArgumentException("File $fileName not found in resources") - val jsScript = jsFileStream.bufferedReader().use { it.readText() } - - context.evaluateString(scope, jsScript, fileName, 1, null) - val jsFunction = scope.get(identifier, scope) - - if (jsFunction is org.mozilla.javascript.Function) { - val jsArgs = arguments.map { arg -> Context.javaToJS(arg, scope) }.toTypedArray() - val result = jsFunction.call(context, scope, scope, jsArgs) - return result as? String ?: "" - } - - return "" + val scope: Scriptable = context.initStandardObjects() + val jsFileStream: InputStream = javaClass.getResourceAsStream("/$fileName") + ?: throw IllegalArgumentException("File $fileName not found in resources") + val jsScript = jsFileStream.bufferedReader().use { it.readText() } + + context.evaluateString(scope, jsScript, fileName, 1, null) + val jsFunction = scope.get(identifier, scope) + + if (jsFunction is org.mozilla.javascript.Function) { + val jsArgs = arguments.map { arg -> Context.javaToJS(arg, scope) }.toTypedArray() + val result = jsFunction.call(context, scope, scope, jsArgs) + return result as? String ?: "" } + return "" + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/providers/Provider.kt b/src/main/kotlin/com/abmo/providers/Provider.kt index 030fd7a..bb82de0 100644 --- a/src/main/kotlin/com/abmo/providers/Provider.kt +++ b/src/main/kotlin/com/abmo/providers/Provider.kt @@ -1,7 +1,16 @@ package com.abmo.providers +/** + * Interface representing a video provider. + * + * This interface defines the contract for obtaining a video ID from a given URL. + */ interface Provider { - + /** + * Retrieves the video ID from the specified URL. + * + * @param url The URL of the video. + * @return The video ID as a String, or null if not found. + */ fun getVideoID(url: String): String? - } \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/services/ProviderDispatcher.kt b/src/main/kotlin/com/abmo/services/ProviderDispatcher.kt index 71b900e..e05cd49 100644 --- a/src/main/kotlin/com/abmo/services/ProviderDispatcher.kt +++ b/src/main/kotlin/com/abmo/services/ProviderDispatcher.kt @@ -3,13 +3,25 @@ package com.abmo.services import com.abmo.providers.* import com.abmo.executor.JavaScriptExecutor import com.abmo.util.getHost +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject -class ProviderDispatcher( - private val javaScriptExecutor: JavaScriptExecutor -) { +class ProviderDispatcher: KoinComponent { + + private val javaScriptExecutor: JavaScriptExecutor by inject() // still this isn't an efficient and clean way to map domains to a provider - // it will become a mess when more hosts are added + // it will become a mess once more hosts are added + /** + * Retrieves the appropriate provider for the given URL. + * + * This method examines the host part of the URL and returns an instance of the corresponding + * provider based on the defined mappings. If the URL's host does not match any known providers, + * it returns a default provider (AbyssToProvider). + * + * @param url The URL for which to find the corresponding provider. + * @return An instance of the Provider that matches the URL's host. + */ fun getProviderForUrl(url: String): Provider { return when(url.getHost()) { "tvphim.my", "tvphim.cx", "tvphim.id" -> TvphimProvider(javaScriptExecutor) diff --git a/src/main/kotlin/com/abmo/services/VideoDownloader.kt b/src/main/kotlin/com/abmo/services/VideoDownloader.kt index 278ef1e..2e8e7d7 100644 --- a/src/main/kotlin/com/abmo/services/VideoDownloader.kt +++ b/src/main/kotlin/com/abmo/services/VideoDownloader.kt @@ -3,6 +3,7 @@ package com.abmo.services import com.abmo.common.Logger import com.abmo.model.* import com.abmo.crypto.CryptoHelper +import com.abmo.executor.JavaScriptExecutor import com.abmo.util.displayProgressBar import com.abmo.util.toJson import com.abmo.util.toReadableTime @@ -16,12 +17,23 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.io.File -class VideoDownloader( - private val cryptoHelper: CryptoHelper -) { +class VideoDownloader: KoinComponent { + private val cryptoHelper: CryptoHelper by inject() + + /** + * Downloads video segments in parallel, decrypts the header of each segment, and merges them into a single MP4 file. + * This function uses coroutines for concurrent downloading with a limit on the number of concurrent downloads. + * The header of each segment is decrypted only once (on the first chunk) using `isHeader` to distinguish it. + * + * @param config The configuration containing settings like output file path and connection limits, resolution. + * @param videoMetadata The metadata of the video, used to generate segment data and the decryption key. + * @throws Exception If there are errors during the download or file operations. + */ suspend fun downloadSegmentsInParallel(config: Config, videoMetadata: Video?) { val simpleVideo = videoMetadata?.toSimpleVideo(config.resolution) val segmentBodies = generateSegmentsBody(simpleVideo) @@ -48,10 +60,10 @@ class VideoDownloader( val downloadJobs = segmentBodies.mapIndexed { i, segmentBody -> async { semaphore.withPermit { - var isFirst = true + var isHeader = true requestSegment(segmentUrl, segmentBody.toJson(), i).collect { chunk -> - val array = if (isFirst) { - isFirst = false + val array = if (isHeader) { + isHeader = false cryptoHelper.decryptAESCTR(chunk, decryptionKey) } else { chunk @@ -108,6 +120,13 @@ class VideoDownloader( } + /** + * Merges video segments into a single MP4 file and cleans up the temporary segment folder. + * + * @param segmentFolderPath The folder containing the video segments. + * @param output The output file where the merged segments will be written. + * @throws IOException If there is an error reading or writing the segment files. + */ private fun mergeSegmentsIntoMp4(segmentFolderPath: File, output: File) { val segmentFiles = segmentFolderPath.listFiles { file -> file.name.startsWith("segment_") } ?.toList() @@ -139,6 +158,13 @@ class VideoDownloader( } + /** + * Sends an HTTP GET request to retrieve and decode video metadata. + * + * @param url The URL to send the GET request to. + * @param headers A map of headers to include in the request. + * @return The decoded video metadata, or null if the extraction or decoding fails. + */ fun getVideoMetaData(url: String, headers: Map?): Video? { Logger.debug("Starting HTTP GET request to $url") val response = Unirest.get(url) @@ -154,7 +180,12 @@ class VideoDownloader( return cryptoHelper.decodeEncryptedString(encryptedVideoData) } - + /** + * Extracts encrypted video metadata from an HTML string using a regex pattern. + * + * @param html The HTML content to extract the metadata from. + * @return The extracted encrypted video metadata, or null if not found. + */ private fun extractEncryptedVideoMetaData(html: String): String? { Logger.debug("Starting extraction of encrypted video metadata from HTML content.") val regex = """JSON\.parse\(atob\("([^"]+)"\)\)""".toRegex() @@ -176,7 +207,13 @@ class VideoDownloader( return "https://${video?.domain}/${video?.id}" } - + /** + * Generates a list of `LongRange` objects for splitting a given size into smaller ranges. + * + * @param size The total size to split into ranges. + * @param step The size of each range (default is 2MB). + * @return A list of `LongRange` representing the size ranges. + */ private fun generateRanges(size: Long, step: Long = 2097152): List { val ranges = mutableListOf() @@ -196,9 +233,13 @@ class VideoDownloader( return ranges } - + /** + * Generates and encrypts the body for segment POST requests based on video size. + * + * @param simpleVideo The video data to generate segments for. + * @return A list of encrypted segment bodies as strings or emptyList if video is size is null. + */ private fun generateSegmentsBody(simpleVideo: SimpleVideo?): List { - Logger.debug("Generating segment POST request body and encrypting the data.") val fragmentList = mutableListOf() val encryptionKey = cryptoHelper.getKey(simpleVideo?.slug) @@ -218,9 +259,18 @@ class VideoDownloader( } + /** + * Sends an HTTP POST request and returns the response body as a flow of byte arrays. + * + * @param url The URL to send the POST request to. + * @param body The body of the POST request. + * @param index An optional index for logging purposes. + * @return A flow of byte arrays representing the response body. + * @throws Exception If the request fails or the response status is not successful. + */ private suspend fun requestSegment(url: String, body: String, index: Int? = null): Flow = flow { println("\n") - Logger.debug("[$index] Starting HTTP POST request to $url with body length: ${body.length}. Body (truncated): \"...${body.takeLast(30)}") + Logger.debug("[$index] Starting HTTP POST request to $url with body length: ${body.length}. Body (truncated): \"...$body") val response = Unirest.post(url) .header("Content-Type", "application/json") .body("""{"hash":$body}""") diff --git a/src/main/kotlin/com/abmo/util/AsciiHelper.kt b/src/main/kotlin/com/abmo/util/AsciiHelper.kt index de4111c..f969052 100644 --- a/src/main/kotlin/com/abmo/util/AsciiHelper.kt +++ b/src/main/kotlin/com/abmo/util/AsciiHelper.kt @@ -17,6 +17,15 @@ fun displayProgressBar(bytesDownloaded: Long, totalSize: Long, startTime: Long) } } +/** + * Displays a progress bar in the console for the download progress. + * + * @param current The index of the current segment being processed. + * @param totalSegments The total number of segments to be processed. + * @param bytesDownloaded The total number of bytes downloaded. + * @param totalDownloaded The total number of segments downloaded so far. + * @param startTime The start time of the download in milliseconds. + */ fun displayProgressBar(current: Int, totalSegments: Int, bytesDownloaded: Long, totalDownloaded: Int, startTime: Long) { val progress = totalDownloaded.toDouble() / totalSegments val barLength = 50 diff --git a/src/main/kotlin/com/abmo/util/Extensions.kt b/src/main/kotlin/com/abmo/util/Extensions.kt index b21d79b..a606e3e 100644 --- a/src/main/kotlin/com/abmo/util/Extensions.kt +++ b/src/main/kotlin/com/abmo/util/Extensions.kt @@ -9,19 +9,31 @@ import java.io.FileNotFoundException fun Any.toJson(): String = Gson().toJson(this) - +/** + * Fetches the HTML document from the URL represented by the string. + * + * @return The parsed `Document` object representing the HTML content. + * @throws IllegalArgumentException if the URL is malformed or cannot be accessed. + * @throws IOException if an I/O error occurs while attempting to retrieve the document. + */ fun String.fetchDocument(): Document = Jsoup.connect(this).get() +/** + * Parses the string as an HTML document using Jsoup. + * + * @return The parsed `Document` object representing the HTML content. + */ fun String.toJsoupDocument(): Document = Jsoup.parse(this) +/** + * Finds and returns the value associated with the given key in a JSON string. + * + * @param key The key to search for. + * @return The corresponding value as a String, or null if not found. + */ fun String.findValueByKey(key: String): String? { - // Regex pattern to match "key": followed by any value (string, number, HTML, etc.) - // It handles cases where the value might be a quoted string or not quoted (e.g., number, null, boolean) val regex = """"$key"\s*:\s*("(?:[^"\\]*(?:\\.[^"\\]*)*)"|[^\s,}]+)""".toRegex() - - // find matches in the JSON string val matchResult = regex.find(this) - return matchResult?.groupValues?.get(1)?.let { if (it.startsWith("\"") && it.endsWith("\"")) { it.substring(1, it.length - 1) diff --git a/src/main/kotlin/com/abmo/util/NumberExtension.kt b/src/main/kotlin/com/abmo/util/NumberExtension.kt index 84e1049..b86f23e 100644 --- a/src/main/kotlin/com/abmo/util/NumberExtension.kt +++ b/src/main/kotlin/com/abmo/util/NumberExtension.kt @@ -1,5 +1,10 @@ package com.abmo.util +/** + * Converts a duration in milliseconds to a human-readable time format. + * + * @return A String representing the duration in hours, minutes, and seconds. + */ fun Long.toReadableTime(): String { val totalSeconds = this / 1000 val seconds = totalSeconds % 60 @@ -14,17 +19,21 @@ fun Long.toReadableTime(): String { } } - -fun formatBytes(bytes: Long?): String { - if (bytes == null) return "" +/** + * Converts a byte value to a human-readable format (KB, MB, GB). + * + * @return A String representing the size in a human-readable format, or an empty String if null. + */ +fun Long?.formatBytes(): String { + if (this == null) return "" val kilobyte = 1024.0 val megabyte = kilobyte * 1024 val gigabyte = megabyte * 1024 return when { - bytes >= gigabyte -> String.format("%.2f GB", bytes / gigabyte) - bytes >= megabyte -> String.format("%.2f MB", bytes / megabyte) - bytes >= kilobyte -> String.format("%.2f KB", bytes / kilobyte) - else -> "$bytes Bytes" + this >= gigabyte -> String.format("%.2f GB", this / gigabyte) + this >= megabyte -> String.format("%.2f MB", this / megabyte) + this >= kilobyte -> String.format("%.2f KB", this / kilobyte) + else -> "$this Bytes" } } \ No newline at end of file diff --git a/src/main/kotlin/com/abmo/util/URLExtension.kt b/src/main/kotlin/com/abmo/util/URLExtension.kt index 1bb8c0e..76281b7 100644 --- a/src/main/kotlin/com/abmo/util/URLExtension.kt +++ b/src/main/kotlin/com/abmo/util/URLExtension.kt @@ -2,6 +2,11 @@ package com.abmo.util import java.net.URI +/** + * Extracts the referer URL from a given String URL. + * + * @return The referer URL as a String, or null if the URL is invalid. + */ fun String.extractReferer(): String? { return try { val url = URI(this).toURL() @@ -11,7 +16,11 @@ fun String.extractReferer(): String? { } } - +/** + * Retrieves the host from a given String URL. + * + * @return The host as a String, or the original String if the URL is invalid. + */ fun String.getHost(): String { return try { URI(this).toURL().host @@ -20,6 +29,11 @@ fun String.getHost(): String { } } +/** + * Checks if the String is a valid URL. + * + * @return True if the String is a valid URL; false otherwise. + */ fun String.isValidUrl(): Boolean { return try { URI(this).toURL() @@ -29,6 +43,12 @@ fun String.isValidUrl(): Boolean { } } +/** + * Retrieves the value of a query parameter from a URL. + * + * @param name The name of the parameter to retrieve. + * @return The parameter value as a String, or null if not found. + */ fun String.getParameter(name: String): String? { val regex = Regex("""[?&]$name=([^&]+)""", RegexOption.IGNORE_CASE) val matchResult = regex.find(this)