diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index deb341a..38bd517 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,14 +2,50 @@ stages: - build - deploy -build:apk: +variables: + FLUTTER_VERSION: "stable" + +build:apk:arm64: stage: build - image: cirrusci/flutter:latest + image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION} script: - - flutter build apk --release + - flutter pub get + - flutter build apk --release --target-platform android-arm64 --split-per-abi artifacts: paths: - - build/app/outputs/flutter-apk/*.apk + - build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + expire_in: 30 days + rules: + - if: $CI_COMMIT_TAG + when: always + - if: $CI_COMMIT_BRANCH =~ /^dev|main/ + when: on_success + +build:apk:arm: + stage: build + image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION} + script: + - flutter pub get + - flutter build apk --release --target-platform android-arm --split-per-abi + artifacts: + paths: + - build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + expire_in: 30 days + rules: + - if: $CI_COMMIT_TAG + when: always + - if: $CI_COMMIT_BRANCH =~ /^dev|main/ + when: on_success + +build:apk:x64: + stage: build + image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION} + script: + - flutter pub get + - flutter build apk --release --target-platform android-x64 --split-per-abi + artifacts: + paths: + - build/app/outputs/flutter-apk/app-x86_64-release.apk expire_in: 30 days rules: - if: $CI_COMMIT_TAG @@ -21,52 +57,162 @@ deploy:release: stage: deploy image: alpine:latest needs: - - build:apk + - build:apk:arm64 + - build:apk:arm + - build:apk:x64 before_script: - - apk add --no-cache curl jq + - apk add --no-cache curl jq coreutils script: - | - VERSION="${CI_COMMIT_TAG:-v0.0.${CI_PIPELINE_ID}}" - APK_FILE=$(ls build/app/outputs/flutter-apk/*.apk | head -n1) - if [ -z "$APK_FILE" ]; then - echo "No APK found!" + if [ -n "$CI_COMMIT_TAG" ]; then + VERSION="$CI_COMMIT_TAG" + else + VERSION="v0.0.${CI_PIPELINE_ID}" + fi + + echo "Creating GitLab Release: $VERSION" + echo "Commit: ${CI_COMMIT_SHORT_SHA}" + echo "Branch: ${CI_COMMIT_BRANCH}" + + APK_ARM64="build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" + APK_ARM32="build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk" + APK_X86="build/app/outputs/flutter-apk/app-x86_64-release.apk" + + RELEASE_DESCRIPTION="## NeoMovies Mobile ${VERSION} + + **Build Info:** + - Commit: \`${CI_COMMIT_SHORT_SHA}\` + - Branch: \`${CI_COMMIT_BRANCH}\` + - Pipeline: [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) + + **Downloads:**" + + FILE_COUNT=0 + + if [ -f "$APK_ARM64" ]; then + FILE_COUNT=$((FILE_COUNT+1)) + SIZE_ARM64=$(du -h "$APK_ARM64" | cut -f1) + RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM64 (arm64-v8a): \`app-arm64-v8a-release.apk\` (${SIZE_ARM64}) - Recommended for modern devices" + fi + + if [ -f "$APK_ARM32" ]; then + FILE_COUNT=$((FILE_COUNT+1)) + SIZE_ARM32=$(du -h "$APK_ARM32" | cut -f1) + RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM32 (armeabi-v7a): \`app-armeabi-v7a-release.apk\` (${SIZE_ARM32}) - For older devices" + fi + + if [ -f "$APK_X86" ]; then + FILE_COUNT=$((FILE_COUNT+1)) + SIZE_X86=$(du -h "$APK_X86" | cut -f1) + RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- x86_64: \`app-x86_64-release.apk\` (${SIZE_X86}) - For emulators" + fi + + if [ $FILE_COUNT -eq 0 ]; then + echo "No release artifacts found!" exit 1 fi - - DESCRIPTION="NeoMovies Mobile ${VERSION} - - Commit: ${CI_COMMIT_SHORT_SHA} - Branch: ${CI_COMMIT_BRANCH} - Pipeline: [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) - - APK: \`$(basename $APK_FILE)\`" - - RELEASE_PAYLOAD=$(cat < 0) { - Log.d(TAG, "Дата создания: ${java.util.Date(metadata.creationDate * 1000)}") - } - - Log.d(TAG, "") - logFileTypeStats(metadata.fileStructure) - Log.d(TAG, "") - logFileStructure(metadata.fileStructure) - Log.d(TAG, "") - logTrackerList(metadata.trackers) - } - - /** - * Выводит структуру файлов в виде дерева - */ - fun logFileStructure(fileStructure: FileStructure) { - Log.d(TAG, "=== СТРУКТУРА ФАЙЛОВ ===") - logDirectoryNode(fileStructure.rootDirectory, "") - } - - /** - * Рекурсивно выводит узел директории - */ - private fun logDirectoryNode(node: DirectoryNode, prefix: String) { - if (node.name.isNotEmpty()) { - Log.d(TAG, "$prefix${node.name}/") - } - - val childPrefix = if (node.name.isEmpty()) prefix else "$prefix " - - // Выводим поддиректории - node.subdirectories.forEach { subDir -> - Log.d(TAG, "$childPrefix├── ${subDir.name}/") - logDirectoryNode(subDir, "$childPrefix│ ") - } - - // Выводим файлы - node.files.forEachIndexed { index, file -> - val isLast = index == node.files.size - 1 && node.subdirectories.isEmpty() - val symbol = if (isLast) "└──" else "├──" - val fileInfo = "${file.name} (${formatFileSize(file.size)}) [${file.extension.uppercase()}]" - Log.d(TAG, "$childPrefix$symbol $fileInfo") - } - } - - /** - * Выводит статистику по типам файлов - */ - fun logFileTypeStats(fileStructure: FileStructure) { - Log.d(TAG, "=== СТАТИСТИКА ПО ТИПАМ ФАЙЛОВ ===") - if (fileStructure.filesByType.isEmpty()) { - Log.d(TAG, "Нет статистики по типам файлов") - return - } - fileStructure.filesByType.forEach { (type, count) -> - val percentage = (count.toFloat() / fileStructure.totalFiles * 100).toInt() - Log.d(TAG, "${type.uppercase()}: $count файлов ($percentage%)") - } - } - - /** - * Alias for MainActivity – just logs structure. - */ - fun logTorrentStructure(metadata: TorrentMetadata) { - logFileStructure(metadata.fileStructure) - } - - /** - * Выводит список трекеров - */ - fun logTrackerList(trackers: List) { - if (trackers.isEmpty()) { - Log.d(TAG, "=== ТРЕКЕРЫ === (нет трекеров)") - return - } - - Log.d(TAG, "=== ТРЕКЕРЫ ===") - trackers.forEachIndexed { index, tracker -> - Log.d(TAG, "${index + 1}. $tracker") - } - } - - /** - * Возвращает текстовое представление структуры файлов - */ - fun getFileStructureText(fileStructure: FileStructure): String { - val sb = StringBuilder() - sb.appendLine("${fileStructure.rootDirectory.name}/") - appendDirectoryNode(fileStructure.rootDirectory, "", sb) - return sb.toString() - } - - /** - * Рекурсивно добавляет узел директории в StringBuilder - */ - private fun appendDirectoryNode(node: DirectoryNode, prefix: String, sb: StringBuilder) { - val childPrefix = if (node.name.isEmpty()) prefix else "$prefix " - - // Добавляем поддиректории - node.subdirectories.forEach { subDir -> - sb.appendLine("$childPrefix└── ${subDir.name}/") - appendDirectoryNode(subDir, "$childPrefix ", sb) - } - - // Добавляем файлы - node.files.forEachIndexed { index, file -> - val isLast = index == node.files.size - 1 && node.subdirectories.isEmpty() - val symbol = if (isLast) "└──" else "├──" - val fileInfo = "${file.name} (${formatFileSize(file.size)})" - sb.appendLine("$childPrefix$symbol $fileInfo") - } - } - - /** - * Возвращает краткую статистику о торренте - */ - fun getTorrentSummary(metadata: TorrentMetadata): String { - return buildString { - appendLine("Название: ${metadata.name}") - appendLine("Размер: ${formatFileSize(metadata.totalSize)}") - appendLine("Файлов: ${metadata.fileStructure.totalFiles}") - appendLine("Хэш: ${metadata.infoHash}") - - if (metadata.fileStructure.filesByType.isNotEmpty()) { - appendLine("\nТипы файлов:") - metadata.fileStructure.filesByType.forEach { (type, count) -> - val percentage = (count.toFloat() / metadata.fileStructure.totalFiles * 100).toInt() - appendLine(" ${type.uppercase()}: $count ($percentage%)") - } - } - } - } - - /** - * Форматирует размер файла в читаемый вид - */ - fun formatFileSize(bytes: Long): String { - if (bytes <= 0) return "0 B" - val units = arrayOf("B", "KB", "MB", "GB", "TB") - val digitGroups = (log(bytes.toDouble(), 1024.0)).toInt() - return "%.1f %s".format( - bytes / 1024.0.pow(digitGroups), - units[digitGroups.coerceAtMost(units.lastIndex)] - ) - } - - /** - * Возвращает иконку для типа файла - */ - fun getFileTypeIcon(extension: String): String { - return when { - extension in setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp") -> "🎬" - extension in setOf("mp3", "flac", "wav", "aac", "ogg", "wma", "m4a", "opus") -> "🎵" - extension in setOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "svg") -> "🖼️" - extension in setOf("pdf", "doc", "docx", "txt", "rtf", "odt") -> "📄" - extension in setOf("zip", "rar", "7z", "tar", "gz", "bz2") -> "📦" - else -> "📁" - } - } - - /** - * Фильтрует файлы по типу - */ - fun filterFilesByType(files: List, type: String): List { - return when (type.lowercase()) { - "video" -> files.filter { it.isVideo } - "audio" -> files.filter { it.isAudio } - "image" -> files.filter { it.isImage } - "document" -> files.filter { it.isDocument } - "archive" -> files.filter { it.isArchive } - else -> files - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt deleted file mode 100644 index 6826f29..0000000 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentMetadataService.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.neo.neomovies_mobile - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import org.libtorrent4j.AddTorrentParams -import kotlinx.coroutines.withContext -import org.libtorrent4j.* -import java.io.File -import java.util.concurrent.Executors - -/** - * 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. - */ -object TorrentMetadataService { - - private const val TAG = "TorrentMetadataService" - private val ioDispatcher = Dispatchers.IO - - /** Lazy SessionManager used for metadata fetch */ - private val session: SessionManager by lazy { - SessionManager().apply { start(SessionParams(SettingsPack())) } - } - - /** 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() - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse magnet", e) - null - } - } - - /** Download full metadata from magnet link */ - suspend fun fetchFullMetadata(uri: String): TorrentMetadata? = withContext(ioDispatcher) { - try { - 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, "Metadata fetch error", e) - null - } - } - - fun cleanup() { - if (session.isRunning) session.stop() - } - - // --- helpers - private fun extractNameFromMagnet(uri: String): String { - val regex = "dn=([^&]+)".toRegex() - val match = regex.find(uri) - return match?.groups?.get(1)?.value?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: "Unknown" - } - - private fun extractHashFromMagnet(uri: String): String { - val regex = "btih:([A-Za-z0-9]{32,40})".toRegex() - val match = regex.find(uri) - return match?.groups?.get(1)?.value ?: "" - } - - private fun buildMetadata(ti: TorrentInfo, originalUri: String): TorrentMetadata { - val fs = ti.files() - val list = MutableList(fs.numFiles()) { idx -> - val size = fs.fileSize(idx) - val path = fs.filePath(idx) - val name = File(path).name - val ext = name.substringAfterLast('.', "").lowercase() - FileInfo(name, path, size, idx, ext) - } - val root = DirectoryNode(ti.name(), "", list) - val structure = FileStructure(root, list.size, fs.totalSize()) - return TorrentMetadata( - name = ti.name(), - infoHash = extractHashFromMagnet(originalUri), - totalSize = fs.totalSize(), - pieceLength = ti.pieceLength(), - numPieces = ti.numPieces(), - fileStructure = structure - ) - } -} diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt deleted file mode 100644 index d759e0c..0000000 --- a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentModels.kt +++ /dev/null @@ -1,66 +0,0 @@ -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 totalSize: Long, - val filesByType: Map = emptyMap(), - val fileTypeStats: 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, - val isArchive: Boolean = false -) \ No newline at end of file