torrent metadata extractor finally work

This commit is contained in:
2025-08-05 13:49:09 +03:00
parent f4b497fb3f
commit 6a8e226a72
4 changed files with 254 additions and 255 deletions

View File

@@ -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()
}
}

View File

@@ -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<String>) {
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<FileInfo>, type: String): List<FileInfo> {
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
}
}
}

View File

@@ -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 {
object TorrentMetadataService {
companion object {
private const val TAG = "TorrentMetadataService"
private const val TAG = "TorrentMetadataService"
private val ioDispatcher = Dispatchers.IO
// Расширения файлов по типам
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")
/** 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<String>()
)
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<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, "Торрент-сервис очищен")
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
)
}
}

View File

@@ -32,7 +32,9 @@ data class TorrentMetadata(
data class FileStructure(
val rootDirectory: DirectoryNode,
val totalFiles: Int,
val filesByType: Map<String, Int> = emptyMap()
val totalSize: Long,
val filesByType: Map<String, Int> = emptyMap(),
val fileTypeStats: Map<String, Int> = 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
)