mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:18:50 +05:00
Рецепт плова:
1. Обжариваем лук до золотистого цвета. 2. Добавляем морковь — жарим до мягкости. 3. Всыпаем нарезанное мясо, жарим до румяной корочки. 4. Добавляем специи: зиру, барбарис, соль. 5. Засыпаем промытый рис, сверху — головка чеснока. 6. Заливаем кипятком на 1-2 см выше риса. 7. Готовим под крышкой на слабом огне до испарения воды.
This commit is contained in:
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.neomovies_mobile"
|
namespace = "com.neo.neomovies_mobile"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = "27.0.12077973"
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// 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.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -42,3 +42,22 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Разрешения для работы с торрентами -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Queries for url_launcher -->
|
<!-- Queries for url_launcher -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.example.neomovies_mobile
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
@@ -1,128 +1,79 @@
|
|||||||
package com.example.neomovies_mobile
|
package com.neo.neomovies_mobile
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import com.google.gson.Gson
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
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()
|
private val gson = Gson()
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
// Initialize torrent service
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TORRENT_CHANNEL)
|
||||||
torrentService = TorrentService(this)
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
"parseMagnetBasicInfo" -> {
|
||||||
when (call.method) {
|
val magnetUri = call.argument<String>("magnetUri")
|
||||||
"getTorrentMetadata" -> {
|
if (magnetUri != null) parseMagnetBasicInfo(magnetUri, result)
|
||||||
val magnetLink = call.argument<String>("magnetLink")
|
else result.error("INVALID_ARGUMENT", "magnetUri is required", null)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
"fetchFullMetadata" -> {
|
||||||
|
val magnetUri = call.argument<String>("magnetUri")
|
||||||
"startDownload" -> {
|
if (magnetUri != null) fetchFullMetadata(magnetUri, result)
|
||||||
val magnetLink = call.argument<String>("magnetLink")
|
else result.error("INVALID_ARGUMENT", "magnetUri is required", null)
|
||||||
val selectedFiles = call.argument<List<Int>>("selectedFiles")
|
|
||||||
val downloadPath = call.argument<String>("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)
|
|
||||||
}
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"getDownloadProgress" -> {
|
private fun parseMagnetBasicInfo(magnetUri: String, result: MethodChannel.Result) {
|
||||||
val infoHash = call.argument<String>("infoHash")
|
coroutineScope.launch {
|
||||||
if (infoHash != null) {
|
try {
|
||||||
val progress = torrentService.getDownloadProgress(infoHash)
|
val basicInfo = torrentMetadataService.parseMagnetBasicInfo(magnetUri)
|
||||||
if (progress != null) {
|
if (basicInfo != null) {
|
||||||
result.success(gson.toJson(progress))
|
result.success(gson.toJson(basicInfo))
|
||||||
} else {
|
} else {
|
||||||
result.error("NOT_FOUND", "Download not found", null)
|
result.error("PARSE_ERROR", "Failed to parse magnet URI", null)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("EXCEPTION", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"pauseDownload" -> {
|
private fun fetchFullMetadata(magnetUri: String, result: MethodChannel.Result) {
|
||||||
val infoHash = call.argument<String>("infoHash")
|
coroutineScope.launch {
|
||||||
if (infoHash != null) {
|
try {
|
||||||
val success = torrentService.pauseDownload(infoHash)
|
val metadata = torrentMetadataService.fetchFullMetadata(magnetUri)
|
||||||
result.success(success)
|
if (metadata != null) {
|
||||||
} else {
|
TorrentDisplayUtils.logTorrentStructure(metadata)
|
||||||
result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
result.success(gson.toJson(metadata))
|
||||||
}
|
} else {
|
||||||
}
|
result.error("METADATA_ERROR", "Failed to fetch torrent metadata", null)
|
||||||
|
|
||||||
"resumeDownload" -> {
|
|
||||||
val infoHash = call.argument<String>("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<String>("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()
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("EXCEPTION", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
if (::torrentService.isInitialized) {
|
coroutineScope.cancel()
|
||||||
torrentService.cleanup()
|
torrentMetadataService.cleanup()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
val trackers = mutableListOf<String>()
|
||||||
|
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<FileInfo>()
|
||||||
|
val fileTypeStats = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
// Создаем несколько примерных файлов на основе имени торрента
|
||||||
|
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, "Торрент-сервис очищен")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.neo.neomovies_mobile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая информация из magnet-ссылки
|
||||||
|
*/
|
||||||
|
data class MagnetBasicInfo(
|
||||||
|
val name: String,
|
||||||
|
val infoHash: String,
|
||||||
|
val trackers: List<String> = 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<String> = emptyList(),
|
||||||
|
val creationDate: Long = 0L,
|
||||||
|
val comment: String = "",
|
||||||
|
val createdBy: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Структура файлов торрента
|
||||||
|
*/
|
||||||
|
data class FileStructure(
|
||||||
|
val rootDirectory: DirectoryNode,
|
||||||
|
val totalFiles: Int,
|
||||||
|
val filesByType: Map<String, Int> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Узел директории в структуре файлов
|
||||||
|
*/
|
||||||
|
data class DirectoryNode(
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val files: List<FileInfo> = emptyList(),
|
||||||
|
val subdirectories: List<DirectoryNode> = 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
|
||||||
|
)
|
||||||
@@ -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<TorrentFileInfo>,
|
|
||||||
@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<String, TorrentHandle>()
|
|
||||||
|
|
||||||
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<TorrentMetadata> = 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<TorrentFileInfo>()
|
|
||||||
|
|
||||||
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<Int>,
|
|
||||||
downloadPath: String? = null
|
|
||||||
): Result<String> = 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<DownloadProgress> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,234 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// Data classes for torrent metadata (matching Kotlin side)
|
/// Data classes for torrent metadata (matching Kotlin side)
|
||||||
|
|
||||||
|
/// Базовая информация из magnet-ссылки
|
||||||
|
class MagnetBasicInfo {
|
||||||
|
final String name;
|
||||||
|
final String infoHash;
|
||||||
|
final List<String> trackers;
|
||||||
|
final int totalSize;
|
||||||
|
|
||||||
|
MagnetBasicInfo({
|
||||||
|
required this.name,
|
||||||
|
required this.infoHash,
|
||||||
|
required this.trackers,
|
||||||
|
this.totalSize = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MagnetBasicInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MagnetBasicInfo(
|
||||||
|
name: json['name'] as String,
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
trackers: List<String>.from(json['trackers'] as List),
|
||||||
|
totalSize: json['totalSize'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<FileInfo> files;
|
||||||
|
final List<DirectoryNode> 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<String, dynamic> 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<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
subdirectories: (json['subdirectories'] as List)
|
||||||
|
.map((dir) => DirectoryNode.fromJson(dir as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
fileCount: json['fileCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Структура файлов торрента
|
||||||
|
class FileStructure {
|
||||||
|
final DirectoryNode rootDirectory;
|
||||||
|
final int totalFiles;
|
||||||
|
final Map<String, int> filesByType;
|
||||||
|
|
||||||
|
FileStructure({
|
||||||
|
required this.rootDirectory,
|
||||||
|
required this.totalFiles,
|
||||||
|
required this.filesByType,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileStructure.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FileStructure(
|
||||||
|
rootDirectory: DirectoryNode.fromJson(json['rootDirectory'] as Map<String, dynamic>),
|
||||||
|
totalFiles: json['totalFiles'] as int,
|
||||||
|
filesByType: Map<String, int>.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<String> 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<String, dynamic> 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<String, dynamic>),
|
||||||
|
trackers: List<String>.from(json['trackers'] as List),
|
||||||
|
creationDate: json['creationDate'] as int,
|
||||||
|
comment: json['comment'] as String,
|
||||||
|
createdBy: json['createdBy'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить плоский список всех файлов
|
||||||
|
List<FileInfo> getAllFiles() {
|
||||||
|
final List<FileInfo> allFiles = [];
|
||||||
|
_collectFiles(fileStructure.rootDirectory, allFiles);
|
||||||
|
return allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collectFiles(DirectoryNode directory, List<FileInfo> result) {
|
||||||
|
result.addAll(directory.files);
|
||||||
|
for (final subdir in directory.subdirectories) {
|
||||||
|
_collectFiles(subdir, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TorrentFileInfo {
|
class TorrentFileInfo {
|
||||||
final String path;
|
final String path;
|
||||||
final int size;
|
final int size;
|
||||||
@@ -110,9 +338,51 @@ class DownloadProgress {
|
|||||||
|
|
||||||
/// Platform service for torrent operations using jlibtorrent on Android
|
/// Platform service for torrent operations using jlibtorrent on Android
|
||||||
class TorrentPlatformService {
|
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<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('parseMagnetBasicInfo', {
|
||||||
|
'magnetUri': magnetUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> 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<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('fetchFullMetadata', {
|
||||||
|
'magnetUri': magnetUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> 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<String> 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<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
|
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
|
||||||
try {
|
try {
|
||||||
final String result = await _channel.invokeMethod('getTorrentMetadata', {
|
final String result = await _channel.invokeMethod('getTorrentMetadata', {
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ class TorrentFileSelectorScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
||||||
TorrentMetadata? _metadata;
|
TorrentMetadataFull? _metadata;
|
||||||
List<TorrentFileInfo> _files = [];
|
List<FileInfo> _files = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _isDownloading = false;
|
bool _isDownloading = false;
|
||||||
bool _selectAll = false;
|
bool _selectAll = false;
|
||||||
|
MagnetBasicInfo? _basicInfo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -37,17 +38,30 @@ class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = await TorrentPlatformService.getTorrentMetadata(widget.magnetLink);
|
// Сначала получаем базовую информацию
|
||||||
|
_basicInfo = await TorrentPlatformService.parseMagnetBasicInfo(widget.magnetLink);
|
||||||
|
|
||||||
|
// Затем пытаемся получить полные метаданные
|
||||||
|
final metadata = await TorrentPlatformService.fetchFullMetadata(widget.magnetLink);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_metadata = metadata;
|
_metadata = metadata;
|
||||||
_files = metadata.files.map((file) => file.copyWith(selected: false)).toList();
|
_files = metadata.getAllFiles().map((file) => file.copyWith(selected: false)).toList();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
// Если не удалось получить полные метаданные, используем базовую информацию
|
||||||
_error = e.toString();
|
if (_basicInfo != null) {
|
||||||
_isLoading = false;
|
setState(() {
|
||||||
});
|
_error = 'Не удалось получить полные метаданные. Показана базовая информация.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +173,7 @@ class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Download button
|
// Download button
|
||||||
if (!_isLoading && _files.isNotEmpty) _buildDownloadButton(),
|
if (!_isLoading && _files.isNotEmpty && _metadata != null) _buildDownloadButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -202,7 +216,15 @@ class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
|||||||
if (_metadata != null) ...[
|
if (_metadata != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -270,6 +292,56 @@ class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (_files.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('Файлы не найдены'),
|
child: Text('Файлы не найдены'),
|
||||||
|
|||||||
Reference in New Issue
Block a user