This commit is contained in:
root
2025-10-02 18:05:23 +00:00
parent 1e4b2f00ba
commit 90113d80b0
4 changed files with 184 additions and 397 deletions

View File

@@ -2,14 +2,50 @@ stages:
- build - build
- deploy - deploy
build:apk: variables:
FLUTTER_VERSION: "stable"
build:apk:arm64:
stage: build stage: build
image: cirrusci/flutter:latest image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
script: script:
- flutter build apk --release - flutter pub get
- flutter build apk --release --target-platform android-arm64 --split-per-abi
artifacts: artifacts:
paths: 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 expire_in: 30 days
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
@@ -21,52 +57,162 @@ deploy:release:
stage: deploy stage: deploy
image: alpine:latest image: alpine:latest
needs: needs:
- build:apk - build:apk:arm64
- build:apk:arm
- build:apk:x64
before_script: before_script:
- apk add --no-cache curl jq - apk add --no-cache curl jq coreutils
script: script:
- | - |
VERSION="${CI_COMMIT_TAG:-v0.0.${CI_PIPELINE_ID}}" if [ -n "$CI_COMMIT_TAG" ]; then
APK_FILE=$(ls build/app/outputs/flutter-apk/*.apk | head -n1) VERSION="$CI_COMMIT_TAG"
if [ -z "$APK_FILE" ]; then else
echo "No APK found!" 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 exit 1
fi fi
DESCRIPTION="NeoMovies Mobile ${VERSION} echo "Found $FILE_COUNT artifact(s) to release"
Commit: ${CI_COMMIT_SHORT_SHA} RELEASE_DATA=$(jq -n \
Branch: ${CI_COMMIT_BRANCH} --arg name "NeoMovies ${VERSION}" \
Pipeline: [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) --arg tag "${VERSION}" \
--arg desc "$RELEASE_DESCRIPTION" \
--arg ref "${CI_COMMIT_SHA}" \
'{name: $name, tag_name: $tag, description: $desc, ref: $ref}')
APK: \`$(basename $APK_FILE)\`" echo "Creating release via GitLab API..."
RELEASE_PAYLOAD=$(cat <<EOF curl --fail-with-body -s -X POST \
{ "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases" \
"name": "NeoMovies ${VERSION}",
"tag_name": "${VERSION}",
"description": "${DESCRIPTION}",
"ref": "${CI_COMMIT_SHA}"
}
EOF
)
curl -s --fail -X POST "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases" \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \ --header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \ --header "Content-Type: application/json" \
--data "$RELEASE_PAYLOAD" || \ --data "$RELEASE_DATA" || \
curl -s -X PUT "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}" \ curl -s -X PUT \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}" \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \ --header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \ --header "Content-Type: application/json" \
--data "$RELEASE_PAYLOAD" --data "$RELEASE_DATA"
echo "Release created/updated: ${CI_PROJECT_URL}/-/releases/${VERSION}" echo ""
echo "APK artifact: ${CI_JOB_URL}/artifacts/browse" echo "Uploading APK files to Package Registry..."
if [ -f "$APK_ARM64" ]; then
echo "Uploading app-arm64-v8a-release.apk..."
curl --fail -s --request PUT \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "$APK_ARM64" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-arm64-v8a-release.apk"
LINK_DATA=$(jq -n \
--arg name "app-arm64-v8a-release.apk" \
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-arm64-v8a-release.apk" \
--arg type "package" \
'{name: $name, url: $url, link_type: $type}')
curl -s --request POST \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "$LINK_DATA" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
echo "ARM64 APK uploaded"
fi
if [ -f "$APK_ARM32" ]; then
echo "Uploading app-armeabi-v7a-release.apk..."
curl --fail -s --request PUT \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "$APK_ARM32" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-armeabi-v7a-release.apk"
LINK_DATA=$(jq -n \
--arg name "app-armeabi-v7a-release.apk" \
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-armeabi-v7a-release.apk" \
--arg type "package" \
'{name: $name, url: $url, link_type: $type}')
curl -s --request POST \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "$LINK_DATA" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
echo "ARM32 APK uploaded"
fi
if [ -f "$APK_X86" ]; then
echo "Uploading app-x86_64-release.apk..."
curl --fail -s --request PUT \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "$APK_X86" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-x86_64-release.apk"
LINK_DATA=$(jq -n \
--arg name "app-x86_64-release.apk" \
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-x86_64-release.apk" \
--arg type "package" \
'{name: $name, url: $url, link_type: $type}')
curl -s --request POST \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "$LINK_DATA" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
echo "x86_64 APK uploaded"
fi
echo ""
echo "================================================"
echo "Release created successfully!"
echo "View release: ${CI_PROJECT_URL}/-/releases/${VERSION}"
echo "Pipeline artifacts: ${CI_JOB_URL}/artifacts/browse"
echo "================================================"
artifacts: artifacts:
paths: paths:
- build/app/outputs/flutter-apk/*.apk - build/app/outputs/flutter-apk/*.apk
expire_in: 30 days expire_in: 90 days
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
when: always when: always

View File

@@ -1,203 +0,0 @@
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 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")
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<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,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<String>()
)
} 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
)
}
}

View File

@@ -1,66 +0,0 @@
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 totalSize: Long,
val filesByType: Map<String, Int> = emptyMap(),
val fileTypeStats: 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,
val isArchive: Boolean = false
)