From 6a8e226a728f4bfee8e07705233cc8bc749464e5 Mon Sep 17 00:00:00 2001 From: Foxix Date: Tue, 5 Aug 2025 13:49:09 +0300 Subject: [PATCH] torrent metadata extractor finally work --- .../com/neo/neomovies_mobile/MainActivity.kt | 7 +- .../neomovies_mobile/TorrentDisplayUtils.kt | 177 +++++++++- .../TorrentMetadataService.kt | 318 ++++-------------- .../com/neo/neomovies_mobile/TorrentModels.kt | 7 +- 4 files changed, 254 insertions(+), 255 deletions(-) diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt index a66cffe..d552a3e 100644 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt @@ -16,7 +16,6 @@ class MainActivity : FlutterActivity() { } private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - private val torrentMetadataService = TorrentMetadataService() private val gson = Gson() override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -43,7 +42,7 @@ class MainActivity : FlutterActivity() { private fun parseMagnetBasicInfo(magnetUri: String, result: MethodChannel.Result) { coroutineScope.launch { try { - val basicInfo = torrentMetadataService.parseMagnetBasicInfo(magnetUri) + val basicInfo = TorrentMetadataService.parseMagnetBasicInfo(magnetUri) if (basicInfo != null) { result.success(gson.toJson(basicInfo)) } else { @@ -58,7 +57,7 @@ class MainActivity : FlutterActivity() { private fun fetchFullMetadata(magnetUri: String, result: MethodChannel.Result) { coroutineScope.launch { try { - val metadata = torrentMetadataService.fetchFullMetadata(magnetUri) + val metadata = TorrentMetadataService.fetchFullMetadata(magnetUri) if (metadata != null) { TorrentDisplayUtils.logTorrentStructure(metadata) result.success(gson.toJson(metadata)) @@ -74,6 +73,6 @@ class MainActivity : FlutterActivity() { override fun onDestroy() { super.onDestroy() coroutineScope.cancel() - torrentMetadataService.cleanup() + TorrentMetadataService.cleanup() } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt index a565b2a..24b401c 100644 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt @@ -8,16 +8,161 @@ object TorrentDisplayUtils { private const val TAG = "TorrentDisplay" - fun logTorrentStructure(metadata: TorrentMetadata) { - Log.d(TAG, "=== СТРУКТУРА ТОРРЕНТА ===") + /** + * Выводит полную информацию о торренте в лог + */ + fun logTorrentInfo(metadata: TorrentMetadata) { + Log.d(TAG, "=== ИНФОРМАЦИЯ О ТОРРЕНТЕ ===") Log.d(TAG, "Название: ${metadata.name}") Log.d(TAG, "Хэш: ${metadata.infoHash}") Log.d(TAG, "Размер: ${formatFileSize(metadata.totalSize)}") Log.d(TAG, "Файлов: ${metadata.fileStructure.totalFiles}") Log.d(TAG, "Частей: ${metadata.numPieces}") Log.d(TAG, "Размер части: ${formatFileSize(metadata.pieceLength.toLong())}") + Log.d(TAG, "Трекеров: ${metadata.trackers.size}") + + if (metadata.comment.isNotEmpty()) { + Log.d(TAG, "Комментарий: ${metadata.comment}") + } + if (metadata.createdBy.isNotEmpty()) { + Log.d(TAG, "Создано: ${metadata.createdBy}") + } + if (metadata.creationDate > 0) { + Log.d(TAG, "Дата создания: ${java.util.Date(metadata.creationDate * 1000)}") + } + + Log.d(TAG, "") + logFileTypeStats(metadata.fileStructure) + Log.d(TAG, "") + logFileStructure(metadata.fileStructure) + Log.d(TAG, "") + logTrackerList(metadata.trackers) } + /** + * Выводит структуру файлов в виде дерева + */ + fun logFileStructure(fileStructure: FileStructure) { + Log.d(TAG, "=== СТРУКТУРА ФАЙЛОВ ===") + logDirectoryNode(fileStructure.rootDirectory, "") + } + + /** + * Рекурсивно выводит узел директории + */ + private fun logDirectoryNode(node: DirectoryNode, prefix: String) { + if (node.name.isNotEmpty()) { + Log.d(TAG, "$prefix${node.name}/") + } + + val childPrefix = if (node.name.isEmpty()) prefix else "$prefix " + + // Выводим поддиректории + node.subdirectories.forEach { subDir -> + Log.d(TAG, "$childPrefix├── ${subDir.name}/") + logDirectoryNode(subDir, "$childPrefix│ ") + } + + // Выводим файлы + node.files.forEachIndexed { index, file -> + val isLast = index == node.files.size - 1 && node.subdirectories.isEmpty() + val symbol = if (isLast) "└──" else "├──" + val fileInfo = "${file.name} (${formatFileSize(file.size)}) [${file.extension.uppercase()}]" + Log.d(TAG, "$childPrefix$symbol $fileInfo") + } + } + + /** + * Выводит статистику по типам файлов + */ + fun logFileTypeStats(fileStructure: FileStructure) { + Log.d(TAG, "=== СТАТИСТИКА ПО ТИПАМ ФАЙЛОВ ===") + if (fileStructure.filesByType.isEmpty()) { + Log.d(TAG, "Нет статистики по типам файлов") + return + } + fileStructure.filesByType.forEach { (type, count) -> + val percentage = (count.toFloat() / fileStructure.totalFiles * 100).toInt() + Log.d(TAG, "${type.uppercase()}: $count файлов ($percentage%)") + } + } + + /** + * Alias for MainActivity – just logs structure. + */ + fun logTorrentStructure(metadata: TorrentMetadata) { + logFileStructure(metadata.fileStructure) + } + + /** + * Выводит список трекеров + */ + fun logTrackerList(trackers: List) { + if (trackers.isEmpty()) { + Log.d(TAG, "=== ТРЕКЕРЫ === (нет трекеров)") + return + } + + Log.d(TAG, "=== ТРЕКЕРЫ ===") + trackers.forEachIndexed { index, tracker -> + Log.d(TAG, "${index + 1}. $tracker") + } + } + + /** + * Возвращает текстовое представление структуры файлов + */ + fun getFileStructureText(fileStructure: FileStructure): String { + val sb = StringBuilder() + sb.appendLine("${fileStructure.rootDirectory.name}/") + appendDirectoryNode(fileStructure.rootDirectory, "", sb) + return sb.toString() + } + + /** + * Рекурсивно добавляет узел директории в StringBuilder + */ + private fun appendDirectoryNode(node: DirectoryNode, prefix: String, sb: StringBuilder) { + val childPrefix = if (node.name.isEmpty()) prefix else "$prefix " + + // Добавляем поддиректории + node.subdirectories.forEach { subDir -> + sb.appendLine("$childPrefix└── ${subDir.name}/") + appendDirectoryNode(subDir, "$childPrefix ", sb) + } + + // Добавляем файлы + node.files.forEachIndexed { index, file -> + val isLast = index == node.files.size - 1 && node.subdirectories.isEmpty() + val symbol = if (isLast) "└──" else "├──" + val fileInfo = "${file.name} (${formatFileSize(file.size)})" + sb.appendLine("$childPrefix$symbol $fileInfo") + } + } + + /** + * Возвращает краткую статистику о торренте + */ + fun getTorrentSummary(metadata: TorrentMetadata): String { + return buildString { + appendLine("Название: ${metadata.name}") + appendLine("Размер: ${formatFileSize(metadata.totalSize)}") + appendLine("Файлов: ${metadata.fileStructure.totalFiles}") + appendLine("Хэш: ${metadata.infoHash}") + + if (metadata.fileStructure.filesByType.isNotEmpty()) { + appendLine("\nТипы файлов:") + metadata.fileStructure.filesByType.forEach { (type, count) -> + val percentage = (count.toFloat() / metadata.fileStructure.totalFiles * 100).toInt() + appendLine(" ${type.uppercase()}: $count ($percentage%)") + } + } + } + } + + /** + * Форматирует размер файла в читаемый вид + */ fun formatFileSize(bytes: Long): String { if (bytes <= 0) return "0 B" val units = arrayOf("B", "KB", "MB", "GB", "TB") @@ -27,4 +172,32 @@ object TorrentDisplayUtils { units[digitGroups.coerceAtMost(units.lastIndex)] ) } + + /** + * Возвращает иконку для типа файла + */ + fun getFileTypeIcon(extension: String): String { + return when { + extension in setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp") -> "🎬" + extension in setOf("mp3", "flac", "wav", "aac", "ogg", "wma", "m4a", "opus") -> "🎵" + extension in setOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "svg") -> "🖼️" + extension in setOf("pdf", "doc", "docx", "txt", "rtf", "odt") -> "📄" + extension in setOf("zip", "rar", "7z", "tar", "gz", "bz2") -> "📦" + else -> "📁" + } + } + + /** + * Фильтрует файлы по типу + */ + fun filterFilesByType(files: List, type: String): List { + return when (type.lowercase()) { + "video" -> files.filter { it.isVideo } + "audio" -> files.filter { it.isAudio } + "image" -> files.filter { it.isImage } + "document" -> files.filter { it.isDocument } + "archive" -> files.filter { it.isArchive } + else -> files + } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt index 20a5129..6826f29 100644 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt @@ -1,266 +1,90 @@ package com.neo.neomovies_mobile import android.util.Log -import kotlinx.coroutines.* -import java.net.URLDecoder +import kotlinx.coroutines.Dispatchers +import org.libtorrent4j.AddTorrentParams +import kotlinx.coroutines.withContext import org.libtorrent4j.* -import org.libtorrent4j.alerts.* -import org.libtorrent4j.swig.* +import java.io.File +import java.util.concurrent.Executors /** - * Упрощенный сервис для получения метаданных торрентов из magnet-ссылок - * Работает без сложных API libtorrent4j, используя только парсинг URI + * Lightweight service that exposes exactly the API used by MainActivity. + * - parseMagnetBasicInfo: quick parsing without network. + * - fetchFullMetadata: downloads metadata and converts to TorrentMetadata. + * - cleanup: stops internal SessionManager. */ -class TorrentMetadataService { - - companion object { - private const val TAG = "TorrentMetadataService" - - // Расширения файлов по типам - private val VIDEO_EXTENSIONS = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp") - private val AUDIO_EXTENSIONS = setOf("mp3", "flac", "wav", "aac", "ogg", "wma", "m4a", "opus") - private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff") - private val DOCUMENT_EXTENSIONS = setOf("pdf", "doc", "docx", "txt", "rtf", "odt", "xls", "xlsx") - private val ARCHIVE_EXTENSIONS = setOf("zip", "rar", "7z", "tar", "gz", "bz2", "xz") +object TorrentMetadataService { + + private const val TAG = "TorrentMetadataService" + private val ioDispatcher = Dispatchers.IO + + /** Lazy SessionManager used for metadata fetch */ + private val session: SessionManager by lazy { + SessionManager().apply { start(SessionParams(SettingsPack())) } } - - /** - * Быстрый парсинг magnet-ссылки для получения базовой информации - */ - suspend fun parseMagnetBasicInfo(magnetUri: String): MagnetBasicInfo? = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Парсинг magnet-ссылки: $magnetUri") - - if (!magnetUri.startsWith("magnet:?")) { - Log.e(TAG, "Неверный формат magnet URI") - return@withContext null - } - - val infoHash = extractInfoHashFromMagnet(magnetUri) - val name = extractNameFromMagnet(magnetUri) - val trackers = extractTrackersFromMagnet(magnetUri) - - if (infoHash == null) { - Log.e(TAG, "Не удалось извлечь info hash из magnet URI") - return@withContext null - } - - val basicInfo = MagnetBasicInfo( - name = name ?: "Unknown", - infoHash = infoHash, - trackers = trackers + + /** Parse basic info (name & hash) from magnet URI without contacting network */ + suspend fun parseMagnetBasicInfo(uri: String): MagnetBasicInfo? = withContext(ioDispatcher) { + return@withContext try { + MagnetBasicInfo( + name = extractNameFromMagnet(uri), + infoHash = extractHashFromMagnet(uri), + trackers = emptyList() ) - - Log.d(TAG, "Базовая информация получена: name=${basicInfo.name}, hash=$infoHash") - return@withContext basicInfo - } catch (e: Exception) { - Log.e(TAG, "Ошибка при парсинге magnet-ссылки", e) - return@withContext null + Log.e(TAG, "Failed to parse magnet", e) + null } } - - /** - * Получение полных метаданных торрента (упрощенная версия) - * Создает фиктивную структуру на основе базовой информации - */ - suspend fun fetchFullMetadata(magnetUri: String): TorrentMetadata? = withContext(Dispatchers.IO) { + + /** Download full metadata from magnet link */ + suspend fun fetchFullMetadata(uri: String): TorrentMetadata? = withContext(ioDispatcher) { try { - Log.d(TAG, "Получение полных метаданных для: $magnetUri") - - // Получаем базовую информацию - val basicInfo = parseMagnetBasicInfo(magnetUri) ?: return@withContext null - - // Создаем фиктивную структуру файлов для демонстрации - val fileStructure = createMockFileStructure(basicInfo.name) - - val metadata = TorrentMetadata( - name = basicInfo.name, - infoHash = basicInfo.infoHash, - totalSize = 1024L * 1024L * 1024L, // 1GB для примера - pieceLength = 1024 * 1024, // 1MB - numPieces = 1024, - fileStructure = fileStructure, - trackers = basicInfo.trackers, - creationDate = System.currentTimeMillis() / 1000, - comment = "Parsed from magnet URI", - createdBy = "NEOMOVIES" - ) - - Log.d(TAG, "Полные метаданные созданы: ${metadata.name}") - return@withContext metadata - + val data = session.fetchMagnet(uri, 30, File("/tmp")) ?: return@withContext null + val ti = TorrentInfo(data) + return@withContext buildMetadata(ti, uri) } catch (e: Exception) { - Log.e(TAG, "Ошибка при получении метаданных торрента", e) - return@withContext null + Log.e(TAG, "Metadata fetch error", e) + null } } - - /** - * Извлечение info hash из magnet URI - */ - private fun extractInfoHashFromMagnet(magnetUri: String): String? { - val regex = Regex("xt=urn:btih:([a-fA-F0-9]{40}|[a-fA-F0-9]{32})") - val match = regex.find(magnetUri) - return match?.groupValues?.get(1) - } - - /** - * Извлечение имени из magnet URI - */ - private fun extractNameFromMagnet(magnetUri: String): String? { - val regex = Regex("dn=([^&]+)") - val match = regex.find(magnetUri) - return match?.groupValues?.get(1)?.let { - try { - URLDecoder.decode(it, "UTF-8") - } catch (e: Exception) { - it // Возвращаем как есть, если декодирование не удалось - } - } - } - - /** - * Извлечение трекеров из magnet URI - */ - private fun extractTrackersFromMagnet(magnetUri: String): List { - val trackers = mutableListOf() - val regex = Regex("tr=([^&]+)") - val matches = regex.findAll(magnetUri) - - matches.forEach { match -> - try { - val tracker = URLDecoder.decode(match.groupValues[1], "UTF-8") - trackers.add(tracker) - } catch (e: Exception) { - Log.w(TAG, "Ошибка декодирования трекера: ${match.groupValues[1]}") - // Добавляем как есть, если декодирование не удалось - trackers.add(match.groupValues[1]) - } - } - - return trackers - } - - /** - * Создание фиктивной структуры файлов для демонстрации - */ - private fun createMockFileStructure(torrentName: String): FileStructure { - val files = mutableListOf() - val fileTypeStats = mutableMapOf() - - // Создаем несколько примерных файлов на основе имени торрента - val isVideoTorrent = VIDEO_EXTENSIONS.any { torrentName.lowercase().contains(it) } - val isAudioTorrent = AUDIO_EXTENSIONS.any { torrentName.lowercase().contains(it) } - - when { - isVideoTorrent -> { - // Видео торрент - files.add(FileInfo( - name = "$torrentName.mkv", - path = "$torrentName.mkv", - size = 800L * 1024L * 1024L, // 800MB - index = 0, - extension = "mkv", - isVideo = true - )) - files.add(FileInfo( - name = "subtitles.srt", - path = "subtitles.srt", - size = 50L * 1024L, // 50KB - index = 1, - extension = "srt", - isDocument = true - )) - fileTypeStats["video"] = 1 - fileTypeStats["document"] = 1 - } - - isAudioTorrent -> { - // Аудио торрент - for (i in 1..10) { - files.add(FileInfo( - name = "Track $i.mp3", - path = "Track $i.mp3", - size = 5L * 1024L * 1024L, // 5MB - index = i - 1, - extension = "mp3", - isAudio = true - )) - } - fileTypeStats["audio"] = 10 - } - - else -> { - // Общий торрент - files.add(FileInfo( - name = "$torrentName.zip", - path = "$torrentName.zip", - size = 500L * 1024L * 1024L, // 500MB - index = 0, - extension = "zip" - )) - files.add(FileInfo( - name = "readme.txt", - path = "readme.txt", - size = 1024L, // 1KB - index = 1, - extension = "txt", - isDocument = true - )) - fileTypeStats["archive"] = 1 - fileTypeStats["document"] = 1 - } - } - - // Создаем корневую директорию - val rootDirectory = DirectoryNode( - name = "root", - path = "", - files = files, - subdirectories = emptyList(), - totalSize = files.sumOf { it.size }, - fileCount = files.size - ) - - return FileStructure( - rootDirectory = rootDirectory, - totalFiles = files.size, - filesByType = fileTypeStats - ) - } - - /** - * Получение расширения файла - */ - private fun getFileExtension(fileName: String): String { - val lastDot = fileName.lastIndexOf('.') - return if (lastDot > 0 && lastDot < fileName.length - 1) { - fileName.substring(lastDot + 1) - } else { - "" - } - } - - /** - * Определение типа файла по расширению - */ - private fun getFileType(extension: String): String { - val ext = extension.lowercase() - return when { - VIDEO_EXTENSIONS.contains(ext) -> "video" - AUDIO_EXTENSIONS.contains(ext) -> "audio" - IMAGE_EXTENSIONS.contains(ext) -> "image" - DOCUMENT_EXTENSIONS.contains(ext) -> "document" - ARCHIVE_EXTENSIONS.contains(ext) -> "archive" - else -> "other" - } - } - - /** - * Освобождение ресурсов - */ + fun cleanup() { - Log.d(TAG, "Торрент-сервис очищен") + if (session.isRunning) session.stop() + } + + // --- helpers + private fun extractNameFromMagnet(uri: String): String { + val regex = "dn=([^&]+)".toRegex() + val match = regex.find(uri) + return match?.groups?.get(1)?.value?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: "Unknown" + } + + private fun extractHashFromMagnet(uri: String): String { + val regex = "btih:([A-Za-z0-9]{32,40})".toRegex() + val match = regex.find(uri) + return match?.groups?.get(1)?.value ?: "" + } + + private fun buildMetadata(ti: TorrentInfo, originalUri: String): TorrentMetadata { + val fs = ti.files() + val list = MutableList(fs.numFiles()) { idx -> + val size = fs.fileSize(idx) + val path = fs.filePath(idx) + val name = File(path).name + val ext = name.substringAfterLast('.', "").lowercase() + FileInfo(name, path, size, idx, ext) + } + val root = DirectoryNode(ti.name(), "", list) + val structure = FileStructure(root, list.size, fs.totalSize()) + return TorrentMetadata( + name = ti.name(), + infoHash = extractHashFromMagnet(originalUri), + totalSize = fs.totalSize(), + pieceLength = ti.pieceLength(), + numPieces = ti.numPieces(), + fileStructure = structure + ) } } diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt index 7987161..d759e0c 100644 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt @@ -32,7 +32,9 @@ data class TorrentMetadata( data class FileStructure( val rootDirectory: DirectoryNode, val totalFiles: Int, - val filesByType: Map = emptyMap() + val totalSize: Long, + val filesByType: Map = emptyMap(), + val fileTypeStats: Map = emptyMap() ) /** @@ -59,5 +61,6 @@ data class FileInfo( val isVideo: Boolean = false, val isAudio: Boolean = false, val isImage: Boolean = false, - val isDocument: Boolean = false + val isDocument: Boolean = false, + val isArchive: Boolean = false ) \ No newline at end of file