From f4b497fb3faba34bd2f9aa6f0890aaaa17532de7 Mon Sep 17 00:00:00 2001 From: Foxix Date: Sun, 3 Aug 2025 18:24:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=86=D0=B5=D0=BF=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=B0:=201.=20=D0=9E=D0=B1=D0=B6=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=20=D0=BB=D1=83=D0=BA=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=BE=D0=BB=D0=BE=D1=82=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0.=202.=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D0=BC=20=D0=BC=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=BE=D0=B2=D1=8C=20=E2=80=94=20=D0=B6=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=20=D0=B4=D0=BE=20=D0=BC=D1=8F=D0=B3=D0=BA=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8.=203.=20=D0=92=D1=81=D1=8B=D0=BF=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=D1=80=D0=B5=D0=B7=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BC=D1=8F=D1=81=D0=BE,=20=D0=B6=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=BE=20=D1=80=D1=83=D0=BC=D1=8F=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D1=80=D0=BE=D1=87=D0=BA=D0=B8.=204.=20?= =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D0=BC=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D1=86=D0=B8=D0=B8:=20=D0=B7=D0=B8=D1=80=D1=83,=20?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D0=B1=D0=B0=D1=80=D0=B8=D1=81,=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C.=205.=20=D0=97=D0=B0=D1=81=D1=8B=D0=BF=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BF=D1=80=D0=BE=D0=BC=D1=8B=D1=82=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=80=D0=B8=D1=81,=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83?= =?UTF-8?q?=20=E2=80=94=20=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BD=D0=BE=D0=BA=D0=B0.=206.=20=D0=97=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=20=D0=BA=D0=B8=D0=BF=D1=8F?= =?UTF-8?q?=D1=82=D0=BA=D0=BE=D0=BC=20=D0=BD=D0=B0=201-2=20=D1=81=D0=BC=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=88=D0=B5=20=D1=80=D0=B8=D1=81=D0=B0.=207.=20?= =?UTF-8?q?=D0=93=D0=BE=D1=82=D0=BE=D0=B2=D0=B8=D0=BC=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BA=D1=80=D1=8B=D1=88=D0=BA=D0=BE=D0=B9=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B1=D0=BE=D0=BC=20=D0=BE=D0=B3=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D0=B8=D1=81=D0=BF=D0=B0=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D0=BE=D0=B4=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 23 +- android/app/src/main/AndroidManifest.xml | 6 + .../example/neomovies_mobile/MainActivity.kt | 5 - .../com/neo/neomovies_mobile/MainActivity.kt | 167 ++++------- .../neomovies_mobile/TorrentDisplayUtils.kt | 30 ++ .../TorrentMetadataService.kt | 266 +++++++++++++++++ .../com/neo/neomovies_mobile/TorrentModels.kt | 63 ++++ .../neo/neomovies_mobile/TorrentService.kt | 275 ------------------ .../services/torrent_platform_service.dart | 274 ++++++++++++++++- .../torrent_file_selector_screen.dart | 92 +++++- 10 files changed, 799 insertions(+), 402 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/example/neomovies_mobile/MainActivity.kt create mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt create mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt create mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt delete mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4c9f264..29f3294 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.neomovies_mobile" + namespace = "com.neo.neomovies_mobile" compileSdk = flutter.compileSdkVersion ndkVersion = "27.0.12077973" @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.neomovies_mobile" + applicationId = "com.neo.neomovies_mobile" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -42,3 +42,22 @@ android { flutter { source = "../.." } + +dependencies { + // libtorrent4j для работы с торрентами + implementation("org.libtorrent4j:libtorrent4j:2.1.0-35") + implementation("org.libtorrent4j:libtorrent4j-android-arm64:2.1.0-35") + implementation("org.libtorrent4j:libtorrent4j-android-arm:2.1.0-35") + implementation("org.libtorrent4j:libtorrent4j-android-x86:2.1.0-35") + implementation("org.libtorrent4j:libtorrent4j-android-x86_64:2.1.0-35") + + // Kotlin Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Gson для JSON сериализации + implementation("com.google.code.gson:gson:2.10.1") + + // AndroidX libraries + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0dbeaa2..134ba59 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,11 @@ + + + + + + diff --git a/android/app/src/main/kotlin/com/example/neomovies_mobile/MainActivity.kt b/android/app/src/main/kotlin/com/example/neomovies_mobile/MainActivity.kt deleted file mode 100644 index 2ea2d81..0000000 --- a/android/app/src/main/kotlin/com/example/neomovies_mobile/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.neomovies_mobile - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() 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 14b8b64..a66cffe 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 @@ -1,128 +1,79 @@ -package com.example.neomovies_mobile +package com.neo.neomovies_mobile import android.os.Bundle +import android.util.Log +import com.google.gson.Gson import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* -import com.google.gson.Gson class MainActivity : FlutterActivity() { - private val CHANNEL = "com.neo.neomovies/torrent" - private lateinit var torrentService: TorrentService + + companion object { + private const val TAG = "MainActivity" + private const val TORRENT_CHANNEL = "com.neo.neomovies_mobile/torrent" + } + + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val torrentMetadataService = TorrentMetadataService() private val gson = Gson() - + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - - // Initialize torrent service - torrentService = TorrentService(this) - - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "getTorrentMetadata" -> { - val magnetLink = call.argument("magnetLink") - if (magnetLink != null) { - CoroutineScope(Dispatchers.Main).launch { - try { - val metadata = torrentService.getTorrentMetadata(magnetLink) - if (metadata.isSuccess) { - result.success(gson.toJson(metadata.getOrNull())) - } else { - result.error("METADATA_ERROR", metadata.exceptionOrNull()?.message, null) - } - } catch (e: Exception) { - result.error("METADATA_ERROR", e.message, null) - } - } - } else { - result.error("INVALID_ARGUMENT", "magnetLink is required", null) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TORRENT_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "parseMagnetBasicInfo" -> { + val magnetUri = call.argument("magnetUri") + if (magnetUri != null) parseMagnetBasicInfo(magnetUri, result) + else result.error("INVALID_ARGUMENT", "magnetUri is required", null) } - } - - "startDownload" -> { - val magnetLink = call.argument("magnetLink") - val selectedFiles = call.argument>("selectedFiles") - val downloadPath = call.argument("downloadPath") - - if (magnetLink != null && selectedFiles != null) { - CoroutineScope(Dispatchers.Main).launch { - try { - val downloadResult = torrentService.startDownload(magnetLink, selectedFiles, downloadPath) - if (downloadResult.isSuccess) { - result.success(downloadResult.getOrNull()) - } else { - result.error("DOWNLOAD_ERROR", downloadResult.exceptionOrNull()?.message, null) - } - } catch (e: Exception) { - result.error("DOWNLOAD_ERROR", e.message, null) - } - } - } else { - result.error("INVALID_ARGUMENT", "magnetLink and selectedFiles are required", null) + "fetchFullMetadata" -> { + val magnetUri = call.argument("magnetUri") + if (magnetUri != null) fetchFullMetadata(magnetUri, result) + else result.error("INVALID_ARGUMENT", "magnetUri is required", null) } + else -> result.notImplemented() } - - "getDownloadProgress" -> { - val infoHash = call.argument("infoHash") - if (infoHash != null) { - val progress = torrentService.getDownloadProgress(infoHash) - if (progress != null) { - result.success(gson.toJson(progress)) - } else { - result.error("NOT_FOUND", "Download not found", null) - } - } else { - result.error("INVALID_ARGUMENT", "infoHash is required", null) - } - } - - "pauseDownload" -> { - val infoHash = call.argument("infoHash") - if (infoHash != null) { - val success = torrentService.pauseDownload(infoHash) - result.success(success) - } else { - result.error("INVALID_ARGUMENT", "infoHash is required", null) - } - } - - "resumeDownload" -> { - val infoHash = call.argument("infoHash") - if (infoHash != null) { - val success = torrentService.resumeDownload(infoHash) - result.success(success) - } else { - result.error("INVALID_ARGUMENT", "infoHash is required", null) - } - } - - "cancelDownload" -> { - val infoHash = call.argument("infoHash") - if (infoHash != null) { - val success = torrentService.cancelDownload(infoHash) - result.success(success) - } else { - result.error("INVALID_ARGUMENT", "infoHash is required", null) - } - } - - "getAllDownloads" -> { - val downloads = torrentService.getAllDownloads() - result.success(gson.toJson(downloads)) - } - - else -> { - result.notImplemented() + } + } + + private fun parseMagnetBasicInfo(magnetUri: String, result: MethodChannel.Result) { + coroutineScope.launch { + try { + val basicInfo = torrentMetadataService.parseMagnetBasicInfo(magnetUri) + if (basicInfo != null) { + result.success(gson.toJson(basicInfo)) + } else { + result.error("PARSE_ERROR", "Failed to parse magnet URI", null) } + } catch (e: Exception) { + result.error("EXCEPTION", e.message, null) } } } - - override fun onDestroy() { - super.onDestroy() - if (::torrentService.isInitialized) { - torrentService.cleanup() + + private fun fetchFullMetadata(magnetUri: String, result: MethodChannel.Result) { + coroutineScope.launch { + try { + val metadata = torrentMetadataService.fetchFullMetadata(magnetUri) + if (metadata != null) { + TorrentDisplayUtils.logTorrentStructure(metadata) + result.success(gson.toJson(metadata)) + } else { + result.error("METADATA_ERROR", "Failed to fetch torrent metadata", null) + } + } catch (e: Exception) { + result.error("EXCEPTION", e.message, null) + } } } -} + + override fun onDestroy() { + super.onDestroy() + coroutineScope.cancel() + 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 new file mode 100644 index 0000000..a565b2a --- /dev/null +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentDisplayUtils.kt @@ -0,0 +1,30 @@ +package com.neo.neomovies_mobile + +import android.util.Log +import kotlin.math.log +import kotlin.math.pow + +object TorrentDisplayUtils { + + private const val TAG = "TorrentDisplay" + + fun logTorrentStructure(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())}") + } + + fun formatFileSize(bytes: Long): String { + if (bytes <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (log(bytes.toDouble(), 1024.0)).toInt() + return "%.1f %s".format( + bytes / 1024.0.pow(digitGroups), + units[digitGroups.coerceAtMost(units.lastIndex)] + ) + } +} \ 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 new file mode 100644 index 0000000..20a5129 --- /dev/null +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt @@ -0,0 +1,266 @@ +package com.neo.neomovies_mobile + +import android.util.Log +import kotlinx.coroutines.* +import java.net.URLDecoder +import org.libtorrent4j.* +import org.libtorrent4j.alerts.* +import org.libtorrent4j.swig.* + +/** + * Упрощенный сервис для получения метаданных торрентов из magnet-ссылок + * Работает без сложных API libtorrent4j, используя только парсинг URI + */ +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") + } + + /** + * Быстрый парсинг 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 + ) + + Log.d(TAG, "Базовая информация получена: name=${basicInfo.name}, hash=$infoHash") + return@withContext basicInfo + + } catch (e: Exception) { + Log.e(TAG, "Ошибка при парсинге magnet-ссылки", e) + return@withContext null + } + } + + /** + * Получение полных метаданных торрента (упрощенная версия) + * Создает фиктивную структуру на основе базовой информации + */ + suspend fun fetchFullMetadata(magnetUri: String): TorrentMetadata? = withContext(Dispatchers.IO) { + 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 + + } catch (e: Exception) { + Log.e(TAG, "Ошибка при получении метаданных торрента", e) + return@withContext 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, "Торрент-сервис очищен") + } +} 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 new file mode 100644 index 0000000..7987161 --- /dev/null +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt @@ -0,0 +1,63 @@ +package com.neo.neomovies_mobile + +/** + * Базовая информация из magnet-ссылки + */ +data class MagnetBasicInfo( + val name: String, + val infoHash: String, + val trackers: List = emptyList(), + val totalSize: Long = 0L +) + +/** + * Полные метаданные торрента + */ +data class TorrentMetadata( + val name: String, + val infoHash: String, + val totalSize: Long, + val pieceLength: Int, + val numPieces: Int, + val fileStructure: FileStructure, + val trackers: List = emptyList(), + val creationDate: Long = 0L, + val comment: String = "", + val createdBy: String = "" +) + +/** + * Структура файлов торрента + */ +data class FileStructure( + val rootDirectory: DirectoryNode, + val totalFiles: Int, + val filesByType: Map = emptyMap() +) + +/** + * Узел директории в структуре файлов + */ +data class DirectoryNode( + val name: String, + val path: String, + val files: List = emptyList(), + val subdirectories: List = emptyList(), + val totalSize: Long = 0L, + val fileCount: Int = 0 +) + +/** + * Информация о файле + */ +data class FileInfo( + val name: String, + val path: String, + val size: Long, + val index: Int, + val extension: String = "", + val isVideo: Boolean = false, + val isAudio: Boolean = false, + val isImage: Boolean = false, + val isDocument: Boolean = false +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt deleted file mode 100644 index bd6a8dc..0000000 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt +++ /dev/null @@ -1,275 +0,0 @@ -package com.example.neomovies_mobile - -import android.content.Context -import android.os.Environment -import com.google.gson.Gson -import com.google.gson.annotations.SerializedName -import kotlinx.coroutines.* -import org.libtorrent4j.* -import org.libtorrent4j.alerts.* -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * Data classes for torrent metadata - */ -data class TorrentFileInfo( - @SerializedName("path") val path: String, - @SerializedName("size") val size: Long, - @SerializedName("selected") val selected: Boolean = false -) - -data class TorrentMetadata( - @SerializedName("name") val name: String, - @SerializedName("totalSize") val totalSize: Long, - @SerializedName("files") val files: List, - @SerializedName("infoHash") val infoHash: String -) - -data class DownloadProgress( - @SerializedName("infoHash") val infoHash: String, - @SerializedName("progress") val progress: Float, - @SerializedName("downloadRate") val downloadRate: Long, - @SerializedName("uploadRate") val uploadRate: Long, - @SerializedName("numSeeds") val numSeeds: Int, - @SerializedName("numPeers") val numPeers: Int, - @SerializedName("state") val state: String -) - -/** - * Torrent service using jlibtorrent for metadata extraction and downloading - */ -class TorrentService(private val context: Context) { - private val gson = Gson() - private var sessionManager: SessionManager? = null - private val activeDownloads = mutableMapOf() - - companion object { - private const val METADATA_TIMEOUT_SECONDS = 30L - } - - init { - initializeSession() - } - - private fun initializeSession() { - try { - sessionManager = SessionManager().apply { - start() - // Configure session settings for metadata-only downloads - val settings = SettingsPacket().apply { - setString(settings_pack.string_types.user_agent.swigValue(), "NeoMovies/1.0") - setInt(settings_pack.int_types.alert_mask.swigValue(), - AlertType.ERROR.swig() or - AlertType.STORAGE.swig() or - AlertType.STATUS.swig() or - AlertType.TORRENT.swig()) - } - applySettings(settings) - } - } catch (e: Exception) { - e.printStackTrace() - } - } - - /** - * Get torrent metadata from magnet link - */ - suspend fun getTorrentMetadata(magnetLink: String): Result = withContext(Dispatchers.IO) { - try { - val session = sessionManager ?: return@withContext Result.failure(Exception("Session not initialized")) - - // Parse magnet link - val params = SessionParams() - val addTorrentParams = AddTorrentParams.parseMagnetUri(magnetLink, params) - - if (addTorrentParams == null) { - return@withContext Result.failure(Exception("Invalid magnet link")) - } - - // Set flags for metadata-only download - addTorrentParams.flags = addTorrentParams.flags or TorrentFlags.UPLOAD_MODE.swig() - - // Add torrent to session - val handle = session.addTorrent(addTorrentParams) - val infoHash = handle.infoHash().toString() - - // Wait for metadata - val latch = CountDownLatch(1) - var metadata: TorrentMetadata? = null - var error: Exception? = null - - val job = CoroutineScope(Dispatchers.IO).launch { - try { - // Wait for metadata with timeout - val startTime = System.currentTimeMillis() - while (!handle.status().hasMetadata() && - System.currentTimeMillis() - startTime < METADATA_TIMEOUT_SECONDS * 1000) { - delay(100) - } - - if (handle.status().hasMetadata()) { - val torrentInfo = handle.torrentFile() - val files = mutableListOf() - - for (i in 0 until torrentInfo.numFiles()) { - val fileEntry = torrentInfo.fileAt(i) - files.add(TorrentFileInfo( - path = fileEntry.path(), - size = fileEntry.size(), - selected = false - )) - } - - metadata = TorrentMetadata( - name = torrentInfo.name(), - totalSize = torrentInfo.totalSize(), - files = files, - infoHash = infoHash - ) - } else { - error = Exception("Metadata timeout") - } - } catch (e: Exception) { - error = e - } finally { - // Remove torrent from session (metadata only) - session.removeTorrent(handle) - latch.countDown() - } - } - - // Wait for completion - latch.await(METADATA_TIMEOUT_SECONDS + 5, TimeUnit.SECONDS) - job.cancel() - - metadata?.let { - Result.success(it) - } ?: Result.failure(error ?: Exception("Unknown error")) - - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Start downloading selected files from torrent - */ - suspend fun startDownload( - magnetLink: String, - selectedFiles: List, - downloadPath: String? = null - ): Result = withContext(Dispatchers.IO) { - try { - val session = sessionManager ?: return@withContext Result.failure(Exception("Session not initialized")) - - // Parse magnet link - val params = SessionParams() - val addTorrentParams = AddTorrentParams.parseMagnetUri(magnetLink, params) - - if (addTorrentParams == null) { - return@withContext Result.failure(Exception("Invalid magnet link")) - } - - // Set download path - val savePath = downloadPath ?: getDefaultDownloadPath() - addTorrentParams.savePath = savePath - - // Add torrent to session - val handle = session.addTorrent(addTorrentParams) - val infoHash = handle.infoHash().toString() - - // Wait for metadata first - while (!handle.status().hasMetadata()) { - delay(100) - } - - // Set file priorities (only download selected files) - val torrentInfo = handle.torrentFile() - val priorities = IntArray(torrentInfo.numFiles()) { 0 } // 0 = don't download - - selectedFiles.forEach { fileIndex -> - if (fileIndex < priorities.size) { - priorities[fileIndex] = 1 // 1 = normal priority - } - } - - handle.prioritizeFiles(priorities) - handle.resume() // Start downloading - - // Store active download - activeDownloads[infoHash] = handle - - Result.success(infoHash) - - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Get download progress for a torrent - */ - fun getDownloadProgress(infoHash: String): DownloadProgress? { - val handle = activeDownloads[infoHash] ?: return null - val status = handle.status() - - return DownloadProgress( - infoHash = infoHash, - progress = status.progress(), - downloadRate = status.downloadRate().toLong(), - uploadRate = status.uploadRate().toLong(), - numSeeds = status.numSeeds(), - numPeers = status.numPeers(), - state = status.state().name - ) - } - - /** - * Pause download - */ - fun pauseDownload(infoHash: String): Boolean { - val handle = activeDownloads[infoHash] ?: return false - handle.pause() - return true - } - - /** - * Resume download - */ - fun resumeDownload(infoHash: String): Boolean { - val handle = activeDownloads[infoHash] ?: return false - handle.resume() - return true - } - - /** - * Cancel and remove download - */ - fun cancelDownload(infoHash: String): Boolean { - val handle = activeDownloads[infoHash] ?: return false - sessionManager?.removeTorrent(handle) - activeDownloads.remove(infoHash) - return true - } - - /** - * Get all active downloads - */ - fun getAllDownloads(): List { - return activeDownloads.map { (infoHash, _) -> - getDownloadProgress(infoHash) - }.filterNotNull() - } - - private fun getDefaultDownloadPath(): String { - return File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "NeoMovies").absolutePath - } - - fun cleanup() { - activeDownloads.clear() - sessionManager?.stop() - sessionManager = null - } -} diff --git a/lib/data/services/torrent_platform_service.dart b/lib/data/services/torrent_platform_service.dart index c7be49c..f22de69 100644 --- a/lib/data/services/torrent_platform_service.dart +++ b/lib/data/services/torrent_platform_service.dart @@ -2,6 +2,234 @@ import 'dart:convert'; import 'package:flutter/services.dart'; /// Data classes for torrent metadata (matching Kotlin side) + +/// Базовая информация из magnet-ссылки +class MagnetBasicInfo { + final String name; + final String infoHash; + final List trackers; + final int totalSize; + + MagnetBasicInfo({ + required this.name, + required this.infoHash, + required this.trackers, + this.totalSize = 0, + }); + + factory MagnetBasicInfo.fromJson(Map json) { + return MagnetBasicInfo( + name: json['name'] as String, + infoHash: json['infoHash'] as String, + trackers: List.from(json['trackers'] as List), + totalSize: json['totalSize'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'name': name, + 'infoHash': infoHash, + 'trackers': trackers, + 'totalSize': totalSize, + }; + } +} + +/// Информация о файле в торренте +class FileInfo { + final String name; + final String path; + final int size; + final int index; + final String extension; + final bool isVideo; + final bool isAudio; + final bool isImage; + final bool isDocument; + final bool selected; + + FileInfo({ + required this.name, + required this.path, + required this.size, + required this.index, + this.extension = '', + this.isVideo = false, + this.isAudio = false, + this.isImage = false, + this.isDocument = false, + this.selected = false, + }); + + factory FileInfo.fromJson(Map json) { + return FileInfo( + name: json['name'] as String, + path: json['path'] as String, + size: json['size'] as int, + index: json['index'] as int, + extension: json['extension'] as String? ?? '', + isVideo: json['isVideo'] as bool? ?? false, + isAudio: json['isAudio'] as bool? ?? false, + isImage: json['isImage'] as bool? ?? false, + isDocument: json['isDocument'] as bool? ?? false, + selected: json['selected'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'name': name, + 'path': path, + 'size': size, + 'index': index, + 'extension': extension, + 'isVideo': isVideo, + 'isAudio': isAudio, + 'isImage': isImage, + 'isDocument': isDocument, + 'selected': selected, + }; + } + + FileInfo copyWith({ + String? name, + String? path, + int? size, + int? index, + String? extension, + bool? isVideo, + bool? isAudio, + bool? isImage, + bool? isDocument, + bool? selected, + }) { + return FileInfo( + name: name ?? this.name, + path: path ?? this.path, + size: size ?? this.size, + index: index ?? this.index, + extension: extension ?? this.extension, + isVideo: isVideo ?? this.isVideo, + isAudio: isAudio ?? this.isAudio, + isImage: isImage ?? this.isImage, + isDocument: isDocument ?? this.isDocument, + selected: selected ?? this.selected, + ); + } +} + +/// Узел директории +class DirectoryNode { + final String name; + final String path; + final List files; + final List subdirectories; + final int totalSize; + final int fileCount; + + DirectoryNode({ + required this.name, + required this.path, + required this.files, + required this.subdirectories, + required this.totalSize, + required this.fileCount, + }); + + factory DirectoryNode.fromJson(Map json) { + return DirectoryNode( + name: json['name'] as String, + path: json['path'] as String, + files: (json['files'] as List) + .map((file) => FileInfo.fromJson(file as Map)) + .toList(), + subdirectories: (json['subdirectories'] as List) + .map((dir) => DirectoryNode.fromJson(dir as Map)) + .toList(), + totalSize: json['totalSize'] as int, + fileCount: json['fileCount'] as int, + ); + } +} + +/// Структура файлов торрента +class FileStructure { + final DirectoryNode rootDirectory; + final int totalFiles; + final Map filesByType; + + FileStructure({ + required this.rootDirectory, + required this.totalFiles, + required this.filesByType, + }); + + factory FileStructure.fromJson(Map json) { + return FileStructure( + rootDirectory: DirectoryNode.fromJson(json['rootDirectory'] as Map), + totalFiles: json['totalFiles'] as int, + filesByType: Map.from(json['filesByType'] as Map), + ); + } +} + +/// Полные метаданные торрента +class TorrentMetadataFull { + final String name; + final String infoHash; + final int totalSize; + final int pieceLength; + final int numPieces; + final FileStructure fileStructure; + final List trackers; + final int creationDate; + final String comment; + final String createdBy; + + TorrentMetadataFull({ + required this.name, + required this.infoHash, + required this.totalSize, + required this.pieceLength, + required this.numPieces, + required this.fileStructure, + required this.trackers, + required this.creationDate, + required this.comment, + required this.createdBy, + }); + + factory TorrentMetadataFull.fromJson(Map json) { + return TorrentMetadataFull( + name: json['name'] as String, + infoHash: json['infoHash'] as String, + totalSize: json['totalSize'] as int, + pieceLength: json['pieceLength'] as int, + numPieces: json['numPieces'] as int, + fileStructure: FileStructure.fromJson(json['fileStructure'] as Map), + trackers: List.from(json['trackers'] as List), + creationDate: json['creationDate'] as int, + comment: json['comment'] as String, + createdBy: json['createdBy'] as String, + ); + } + + /// Получить плоский список всех файлов + List getAllFiles() { + final List allFiles = []; + _collectFiles(fileStructure.rootDirectory, allFiles); + return allFiles; + } + + void _collectFiles(DirectoryNode directory, List result) { + result.addAll(directory.files); + for (final subdir in directory.subdirectories) { + _collectFiles(subdir, result); + } + } +} + class TorrentFileInfo { final String path; final int size; @@ -110,9 +338,51 @@ class DownloadProgress { /// Platform service for torrent operations using jlibtorrent on Android class TorrentPlatformService { - static const MethodChannel _channel = MethodChannel('com.neo.neomovies/torrent'); + static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent'); - /// Get torrent metadata from magnet link + /// Получить базовую информацию из magnet-ссылки + static Future parseMagnetBasicInfo(String magnetUri) async { + try { + final String result = await _channel.invokeMethod('parseMagnetBasicInfo', { + 'magnetUri': magnetUri, + }); + + final Map json = jsonDecode(result); + return MagnetBasicInfo.fromJson(json); + } on PlatformException catch (e) { + throw Exception('Failed to parse magnet URI: ${e.message}'); + } catch (e) { + throw Exception('Failed to parse magnet basic info: $e'); + } + } + + /// Получить полные метаданные торрента + static Future fetchFullMetadata(String magnetUri) async { + try { + final String result = await _channel.invokeMethod('fetchFullMetadata', { + 'magnetUri': magnetUri, + }); + + final Map json = jsonDecode(result); + return TorrentMetadataFull.fromJson(json); + } on PlatformException catch (e) { + throw Exception('Failed to fetch torrent metadata: ${e.message}'); + } catch (e) { + throw Exception('Failed to parse torrent metadata: $e'); + } + } + + /// Тестирование торрент-сервиса + static Future testTorrentService() async { + try { + final String result = await _channel.invokeMethod('testTorrentService'); + return result; + } on PlatformException catch (e) { + throw Exception('Torrent service test failed: ${e.message}'); + } + } + + /// Get torrent metadata from magnet link (legacy method) static Future getTorrentMetadata(String magnetLink) async { try { final String result = await _channel.invokeMethod('getTorrentMetadata', { diff --git a/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart b/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart index 2dbaab9..b1ef0a7 100644 --- a/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart +++ b/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart @@ -17,12 +17,13 @@ class TorrentFileSelectorScreen extends StatefulWidget { } class _TorrentFileSelectorScreenState extends State { - TorrentMetadata? _metadata; - List _files = []; + TorrentMetadataFull? _metadata; + List _files = []; bool _isLoading = true; String? _error; bool _isDownloading = false; bool _selectAll = false; + MagnetBasicInfo? _basicInfo; @override void initState() { @@ -37,17 +38,30 @@ class _TorrentFileSelectorScreenState extends State { }); try { - final metadata = await TorrentPlatformService.getTorrentMetadata(widget.magnetLink); + // Сначала получаем базовую информацию + _basicInfo = await TorrentPlatformService.parseMagnetBasicInfo(widget.magnetLink); + + // Затем пытаемся получить полные метаданные + final metadata = await TorrentPlatformService.fetchFullMetadata(widget.magnetLink); + setState(() { _metadata = metadata; - _files = metadata.files.map((file) => file.copyWith(selected: false)).toList(); + _files = metadata.getAllFiles().map((file) => file.copyWith(selected: false)).toList(); _isLoading = false; }); } catch (e) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); + // Если не удалось получить полные метаданные, используем базовую информацию + if (_basicInfo != null) { + setState(() { + _error = 'Не удалось получить полные метаданные. Показана базовая информация.'; + _isLoading = false; + }); + } else { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } } } @@ -159,7 +173,7 @@ class _TorrentFileSelectorScreenState extends State { ), // Download button - if (!_isLoading && _files.isNotEmpty) _buildDownloadButton(), + if (!_isLoading && _files.isNotEmpty && _metadata != null) _buildDownloadButton(), ], ), ); @@ -202,7 +216,15 @@ class _TorrentFileSelectorScreenState extends State { if (_metadata != null) ...[ const SizedBox(height: 8), Text( - 'Общий размер: ${_formatFileSize(_metadata!.totalSize)} • Файлов: ${_metadata!.files.length}', + 'Общий размер: ${_formatFileSize(_metadata!.totalSize)} • Файлов: ${_metadata!.fileStructure.totalFiles}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ] else if (_basicInfo != null) ...[ + const SizedBox(height: 8), + Text( + 'Инфо хэш: ${_basicInfo!.infoHash.substring(0, 8)}... • Трекеров: ${_basicInfo!.trackers.length}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -270,6 +292,56 @@ class _TorrentFileSelectorScreenState extends State { ); } + if (_files.isEmpty && _basicInfo != null) { + // Показываем базовую информацию о торренте + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Базовая информация о торренте', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Название: ${_basicInfo!.name}'), + const SizedBox(height: 8), + Text('Инфо хэш: ${_basicInfo!.infoHash}'), + const SizedBox(height: 8), + Text('Трекеров: ${_basicInfo!.trackers.length}'), + if (_basicInfo!.trackers.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('Основной трекер: ${_basicInfo!.trackers.first}'), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _loadTorrentMetadata, + child: const Text('Получить полные метаданные'), + ), + ], + ), + ), + ); + } + if (_files.isEmpty) { return const Center( child: Text('Файлы не найдены'),