Compare commits

..

16 Commits

Author SHA1 Message Date
factory-droid[bot]
86611976a7 Fix API auth flow and poster URLs
- Fix authorization issues by improving error handling for unverified accounts
- Enable auto-login after successful email verification
- Fix poster fetching to use NeoMovies API instead of TMDB directly
- Add missing video player models (VideoQuality, AudioTrack, Subtitle, PlayerSettings)
- Add video_player and chewie dependencies for native video playback
- Update Movie model to use API images endpoint for better CDN control

Resolves authentication and image loading issues.
2025-10-03 06:00:37 +00:00
root
e70c477238 fix auto mirror 2025-10-03 05:26:42 +00:00
root
7b8f64842a add auto mirror from gh to gl 2025-10-03 05:20:54 +00:00
root
b167c73699 ed readme 2025-10-03 04:12:18 +00:00
root
23a3068b37 ed readme 2025-10-03 04:07:50 +00:00
factory-droid[bot]
fd296d800f fix api bugs 2025-10-02 21:40:20 +00:00
root
c30b1b2464 fix v2 github actions 2025-10-02 21:10:39 +00:00
root
13de6a5417 add github actions deploy 2025-10-02 20:21:50 +00:00
factory-droid[bot]
7201d2e7dc v0.0.3 2025-10-02 19:54:32 +00:00
factory-droid[bot]
2ba77aee3a fix 2025-10-02 18:47:08 +00:00
factory-droid[bot]
ca409fabdd better 2025-10-02 18:28:52 +00:00
root
90113d80b0 better 2025-10-02 18:05:23 +00:00
root
1e4b2f00ba Fix 2025-10-02 17:49:43 +00:00
root
82850b4556 fix gitlab ci 2025-10-02 17:43:34 +00:00
root
a48f947d65 better 2025-10-02 17:15:01 +00:00
root
545b5e0d68 v0.0.2 2025-10-02 17:09:36 +00:00
38 changed files with 1384 additions and 1835 deletions

View File

@@ -1,185 +0,0 @@
name: Build NeoMovies Mobile
on:
push:
branches: [ dev, feature/torrent-engine-integration ]
pull_request:
branches: [ dev ]
workflow_dispatch:
env:
FLUTTER_VERSION: '3.35.5'
JAVA_VERSION: '17'
jobs:
# ============================================
# Сборка TorrentEngine модуля
# ============================================
build-torrent-engine:
name: Build TorrentEngine Library
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: wrapper
- name: Build TorrentEngine AAR
working-directory: android
run: |
./gradlew :torrentengine:assembleRelease \
--no-daemon \
--parallel \
--build-cache \
-Dorg.gradle.jvmargs="-Xmx2g -XX:MaxMetaspaceSize=512m"
- name: Upload TorrentEngine AAR
uses: actions/upload-artifact@v4
with:
name: torrentengine-aar
path: android/torrentengine/build/outputs/aar/*.aar
retention-days: 7
# ============================================
# Сборка Debug APK
# ============================================
build-debug-apk:
name: Build Debug APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
cache: 'gradle'
- name: Flutter Doctor
run: flutter doctor -v
- name: Get Flutter dependencies
run: flutter pub get
- name: Build Debug APK
run: |
flutter build apk \
--debug \
--target-platform android-arm64
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: build/app/outputs/flutter-apk/app-debug.apk
retention-days: 7
# ============================================
# Сборка Release APK
# ============================================
build-release-apk:
name: Build Release APK
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/dev'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
cache: 'gradle'
- name: Get Flutter dependencies
run: flutter pub get
- name: Build Release APK (split per ABI)
run: |
flutter build apk \
--release \
--split-per-abi \
--target-platform android-arm64
- name: Upload Release APK (ARM64)
uses: actions/upload-artifact@v4
with:
name: release-apk-arm64
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
retention-days: 30
# ============================================
# Анализ кода
# ============================================
code-quality:
name: Code Quality Checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
- name: Get Flutter dependencies
run: flutter pub get
- name: Flutter Analyze
run: flutter analyze --no-fatal-infos
continue-on-error: true
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Android Lint
working-directory: android
run: ./gradlew lint --no-daemon
continue-on-error: true
- name: Upload Lint Reports
uses: actions/upload-artifact@v4
if: always()
with:
name: lint-reports
path: |
android/app/build/reports/lint-results*.html
android/torrentengine/build/reports/lint-results*.html
retention-days: 7

View File

@@ -1,122 +0,0 @@
# NeoMovies GitHub Actions CI/CD for Flutter (Android APK + Linux desktop)
# Requires GitHub-hosted Ubuntu runners.
name: Flutter CI
on:
push:
branches: [ main ]
pull_request:
workflow_dispatch:
release:
types: [created]
env:
FLUTTER_VERSION: "3.22.1"
jobs:
# ---------------------------------------------------------------------------
test:
name: Test & Analyze
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter ${{ env.FLUTTER_VERSION }} (beta)
uses: subosito/flutter-action@v2
with:
channel: beta
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Get dependencies
run: flutter pub get
- name: Static analysis
run: flutter analyze --no-pub --fatal-infos --fatal-warnings
- name: Run tests
run: flutter test --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# ---------------------------------------------------------------------------
build_android:
name: Build Android APK
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: beta
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Get dependencies
run: flutter pub get
- name: Build release APK & AAB
run: |
flutter build apk --release
flutter build appbundle --release
- name: Upload APK & AAB artifacts
uses: actions/upload-artifact@v4
with:
name: android-build
path: |
build/app/outputs/flutter-apk/app-release.apk
build/app/outputs/bundle/release/app-release.aab
# ---------------------------------------------------------------------------
build_linux:
name: Build Linux desktop bundle
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: beta
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Linux build dependencies
run: sudo apt-get update && sudo apt-get install -y libjsoncpp-dev libsecret-1-dev clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
- name: Enable Linux desktop and get deps
run: |
flutter config --enable-linux-desktop
flutter pub get
- name: Build Linux release bundle
run: flutter build linux --release
- name: Upload Linux bundle artifact
uses: actions/upload-artifact@v4
with:
name: linux-build
path: build/linux/x64/release/bundle/
# ---------------------------------------------------------------------------
release_assets:
name: Attach assets to GitHub Release
if: github.event_name == 'release'
needs: [build_android, build_linux]
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
android-build/app-release.apk
android-build/app-release.aab
linux-build/**

76
.github/workflows/gitlab-mirror.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Full Mirror to GitLab
on:
push:
branches:
- "**"
pull_request:
types: [opened, reopened, closed, edited]
issues:
types: [opened, edited, closed, reopened]
jobs:
mirror-code:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Fetch GitLab branch
run: |
git remote add gitlab https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/foxixus/neomovies_mobile.git
git fetch gitlab main || true
- name: Check for differences with GitLab
id: diffcheck
run: |
# Если нет ветки main на GitLab, пушим всегда
if ! git rev-parse gitlab/main >/dev/null 2>&1; then
echo "has_diff=true" >> $GITHUB_OUTPUT
else
DIFF=$(git rev-list --left-right --count HEAD...gitlab/main | awk '{print $1}')
if [[ "$DIFF" -gt 0 ]]; then
echo "has_diff=true" >> $GITHUB_OUTPUT
else
echo "has_diff=false" >> $GITHUB_OUTPUT
fi
fi
- name: Push to GitLab if there are changes
if: steps.diffcheck.outputs.has_diff == 'true'
run: git push gitlab HEAD:main
mirror-issues:
runs-on: ubuntu-latest
if: github.event_name == 'issues'
steps:
- name: Sync issue to GitLab
run: |
curl --request POST "https://gitlab.com/api/v4/projects/foxixus%2Fneomovies_mobile/issues" \
--header "PRIVATE-TOKEN: ${{ secrets.GITLAB_TOKEN }}" \
--header "Content-Type: application/json" \
--data "{
\"title\": \"${{ github.event.issue.title }}\",
\"description\": \"${{ github.event.issue.body }}\"
}"
mirror-prs:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Sync PR to GitLab MR
run: |
curl --request POST "https://gitlab.com/api/v4/projects/foxixus%2Fneomovies_mobile/merge_requests" \
--header "PRIVATE-TOKEN: ${{ secrets.GITLAB_TOKEN }}" \
--header "Content-Type: application/json" \
--data "{
\"title\": \"${{ github.event.pull_request.title }}\",
\"source_branch\": \"${{ github.event.pull_request.head.ref }}\",
\"target_branch\": \"${{ github.event.pull_request.base.ref }}\",
\"description\": \"${{ github.event.pull_request.body }}\"
}"

211
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,211 @@
name: Build and Release
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- dev
jobs:
build-arm64:
name: Build APK (ARM64)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Build ARM64 APK
run: flutter build apk --release --target-platform android-arm64 --split-per-abi
- name: Upload ARM64 APK
uses: actions/upload-artifact@v4
with:
name: apk-arm64
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
retention-days: 30
build-arm32:
name: Build APK (ARM32)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Build ARM32 APK
run: flutter build apk --release --target-platform android-arm --split-per-abi
- name: Upload ARM32 APK
uses: actions/upload-artifact@v4
with:
name: apk-arm32
path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
retention-days: 30
build-x64:
name: Build APK (x86_64)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Build x86_64 APK
run: flutter build apk --release --target-platform android-x64 --split-per-abi
- name: Upload x86_64 APK
uses: actions/upload-artifact@v4
with:
name: apk-x64
path: build/app/outputs/flutter-apk/app-x86_64-release.apk
retention-days: 30
release:
name: Create Release
runs-on: ubuntu-latest
needs: [build-arm64, build-arm32, build-x64]
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download ARM64 APK
uses: actions/download-artifact@v4
with:
name: apk-arm64
path: ./apks
- name: Download ARM32 APK
uses: actions/download-artifact@v4
with:
name: apk-arm32
path: ./apks
- name: Download x86_64 APK
uses: actions/download-artifact@v4
with:
name: apk-x64
path: ./apks
- name: Generate version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="v0.0.${{ github.run_number }}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Get file sizes
id: sizes
run: |
ARM64_SIZE=$(du -h ./apks/app-arm64-v8a-release.apk | cut -f1)
ARM32_SIZE=$(du -h ./apks/app-armeabi-v7a-release.apk | cut -f1)
X64_SIZE=$(du -h ./apks/app-x86_64-release.apk | cut -f1)
echo "arm64_size=$ARM64_SIZE" >> $GITHUB_OUTPUT
echo "arm32_size=$ARM32_SIZE" >> $GITHUB_OUTPUT
echo "x64_size=$X64_SIZE" >> $GITHUB_OUTPUT
- name: Create Release Notes
id: notes
run: |
cat << EOF > release_notes.md
## NeoMovies Mobile ${{ steps.version.outputs.version }}
**Build Info:**
- Commit: \`${{ github.sha }}\`
- Branch: \`${{ github.ref_name }}\`
- Workflow Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
**Downloads:**
- **ARM64 (arm64-v8a)**: \`app-arm64-v8a-release.apk\` (${{ steps.sizes.outputs.arm64_size }}) - Recommended for modern devices
- **ARM32 (armeabi-v7a)**: \`app-armeabi-v7a-release.apk\` (${{ steps.sizes.outputs.arm32_size }}) - For older devices
- **x86_64**: \`app-x86_64-release.apk\` (${{ steps.sizes.outputs.x64_size }}) - For emulators
### What's Changed
See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }})
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.version }}
name: NeoMovies ${{ steps.version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
files: |
./apks/app-arm64-v8a-release.apk
./apks/app-armeabi-v7a-release.apk
./apks/app-x86_64-release.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
run: |
echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Release URL:** ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### APK Files:" >> $GITHUB_STEP_SUMMARY
echo "- ARM64: ${{ steps.sizes.outputs.arm64_size }}" >> $GITHUB_STEP_SUMMARY
echo "- ARM32: ${{ steps.sizes.outputs.arm32_size }}" >> $GITHUB_STEP_SUMMARY
echo "- x86_64: ${{ steps.sizes.outputs.x64_size }}" >> $GITHUB_STEP_SUMMARY

136
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
name: Test and Analyze
on:
push:
branches:
- main
- dev
- 'feature/**'
pull_request:
branches:
- main
- dev
jobs:
flutter-analyze:
name: Flutter Analyze
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Run Flutter Analyze
run: flutter analyze
- name: Check formatting
run: dart format --set-exit-if-changed .
flutter-test:
name: Flutter Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Run tests
run: flutter test --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: false
if: always()
android-lint:
name: Android Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Run Android Lint
run: |
cd android
chmod +x gradlew
./gradlew lint
- name: Upload lint reports
uses: actions/upload-artifact@v4
with:
name: android-lint-reports
path: |
android/app/build/reports/lint-*.html
android/torrentengine/build/reports/lint-*.html
retention-days: 7
if: always()
build-debug:
name: Build Debug APK
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/dev'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Build Debug APK
run: flutter build apk --debug
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: apk-debug
path: build/app/outputs/flutter-apk/app-debug.apk
retention-days: 7

View File

@@ -1,207 +1,220 @@
stages:
- build
- test
- deploy
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs='-Xmx2048m' -Dorg.gradle.parallel=true"
GRADLE_USER_HOME: "${CI_PROJECT_DIR}/.gradle"
PUB_CACHE: "${CI_PROJECT_DIR}/.pub-cache"
FLUTTER_VERSION: "stable"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .gradle/wrapper
- .gradle/caches/modules-2
- .pub-cache/
policy: pull-push
build:torrent-engine:
build:apk:arm64:
stage: build
image: mingc/android-build-box:latest
before_script:
- echo "sdk.dir=${ANDROID_SDK_ROOT:-/opt/android-sdk}" > android/local.properties
# Clean up old Gradle cache to free space
- rm -rf .gradle/caches/transforms-*
- rm -rf .gradle/caches/*/transforms
- find .gradle/caches -name "*.lock" -type f -delete || true
script:
- cd android
- chmod +x gradlew
- ./gradlew clean :torrentengine:assembleRelease --no-daemon --stacktrace
artifacts:
paths:
- android/torrentengine/build/outputs/aar/*.aar
expire_in: 30 days
rules:
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^feature\//
- if: $CI_COMMIT_TAG
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
build:apk-debug:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
script:
- flutter pub get
- flutter build apk --debug
artifacts:
paths:
- build/app/outputs/flutter-apk/app-debug.apk
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_COMMIT_BRANCH =~ /^feature\//
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
build:apk-release:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
script:
- flutter pub get
- flutter build apk --release --split-per-abi
- flutter build apk --release --target-platform android-arm64 --split-per-abi
artifacts:
paths:
- 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_BRANCH == "dev"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^feature\//
- if: $CI_COMMIT_TAG
allow_failure: true
test:flutter-analyze:
stage: test
image: ghcr.io/cirruslabs/flutter:stable
script:
- flutter pub get
- flutter analyze
rules:
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
test:android-lint:
stage: test
image: mingc/android-build-box:latest
script:
- cd android
- chmod +x gradlew
- ./gradlew lint --no-daemon
artifacts:
paths:
- android/app/build/reports/lint-*.html
- android/torrentengine/build/reports/lint-*.html
expire_in: 1 week
when: always
rules:
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
when: always
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
when: on_success
deploy:release:
stage: deploy
image: alpine:latest
needs:
- build:apk-release
- build:torrent-engine
- 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:
- |
# Определяем версию релиза
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}"
echo "Creating GitLab Release: $VERSION"
echo "Commit: ${CI_COMMIT_SHORT_SHA}"
echo "Branch: ${CI_COMMIT_BRANCH}"
# Проверяем наличие APK файлов
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"
AAR_TORRENT="android/torrentengine/build/outputs/aar/torrentengine-release.aar"
# Создаем описание релиза
RELEASE_DESCRIPTION="## NeoMovies Mobile ${VERSION}
**Build Info:**
- Commit: \`${CI_COMMIT_SHORT_SHA}\`
- Branch: \`${CI_COMMIT_BRANCH}\`
- Pipeline: [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL})
**Downloads:**"
**Downloads:**
"
# Подсчитываем файлы
FILE_COUNT=0
[ -f "$APK_ARM64" ] && FILE_COUNT=$((FILE_COUNT+1)) && RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM64 APK: \`app-arm64-v8a-release.apk\`"
[ -f "$APK_ARM32" ] && FILE_COUNT=$((FILE_COUNT+1)) && RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM32 APK: \`app-armeabi-v7a-release.apk\`"
[ -f "$APK_X86" ] && FILE_COUNT=$((FILE_COUNT+1)) && RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- x86_64 APK: \`app-x86_64-release.apk\`"
[ -f "$AAR_TORRENT" ] && FILE_COUNT=$((FILE_COUNT+1)) && RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- TorrentEngine Library: \`torrentengine-release.aar\`"
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!"
echo "No release artifacts found!"
exit 1
fi
echo "Found $FILE_COUNT artifact(s) to release"
echo "Found $FILE_COUNT artifact(s) to release"
# Создаем релиз через GitLab API
RELEASE_PAYLOAD=$(cat <<EOF
{
"name": "NeoMovies ${VERSION}",
"tag_name": "${VERSION}",
"description": "${RELEASE_DESCRIPTION}",
"ref": "${CI_COMMIT_SHA}",
"assets": {
"links": []
}
}
EOF
)
RELEASE_DATA=$(jq -n \
--arg name "NeoMovies ${VERSION}" \
--arg tag "${VERSION}" \
--arg desc "$RELEASE_DESCRIPTION" \
--arg ref "${CI_COMMIT_SHA}" \
'{name: $name, tag_name: $tag, description: $desc, ref: $ref}')
echo "🚀 Creating release via GitLab API..."
echo "Creating release via GitLab API..."
RESPONSE=$(curl --fail-with-body -s -X POST \
curl --fail-with-body -s -X POST \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases" \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "${RELEASE_PAYLOAD}" || echo "FAILED")
--data "$RELEASE_DATA" || \
curl -s -X PUT \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}" \
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "$RELEASE_DATA"
if [ "$RESPONSE" = "FAILED" ]; then
echo "⚠️ Release API call failed, trying alternative method..."
# Если релиз уже существует, пробуем обновить
curl -s -X PUT \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}" \
echo ""
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 "${RELEASE_PAYLOAD}"
--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 "✅ Release created successfully!"
echo "🔗 View release: ${CI_PROJECT_URL}/-/releases/${VERSION}"
echo "📦 Pipeline artifacts: ${CI_JOB_URL}/artifacts/browse"
echo "================================================"
echo "Release created successfully!"
echo "View release: ${CI_PROJECT_URL}/-/releases/${VERSION}"
echo "Pipeline artifacts: ${CI_JOB_URL}/artifacts/browse"
echo "================================================"
artifacts:
paths:
- build/app/outputs/flutter-apk/*.apk
- android/torrentengine/build/outputs/aar/*.aar
expire_in: 90 days
rules:
- if: $CI_COMMIT_TAG
when: always
- if: $CI_COMMIT_BRANCH == "dev"
when: on_success
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
when: on_success

View File

@@ -1,304 +0,0 @@
# 🚀 CI/CD Configuration для NeoMovies Mobile
## 📋 Обзор
Автоматическая сборка APK и TorrentEngine модуля с оптимизацией использования RAM.
---
## 🏗️ Конфигурации
### 1. **GitLab CI/CD** (`.gitlab-ci.yml`)
Основная конфигурация для GitLab:
#### **Stages:**
- **build** - Сборка APK и AAR
- **test** - Анализ кода и тесты
- **deploy** - Публикация релизов
#### **Jobs:**
| Job | Описание | Артефакты | Ветки |
|-----|----------|-----------|-------|
| `build:torrent-engine` | Сборка TorrentEngine AAR | `*.aar` | dev, feature/*, MR |
| `build:apk-debug` | Сборка Debug APK | `app-debug.apk` | dev, feature/*, MR |
| `build:apk-release` | Сборка Release APK | `app-arm64-v8a-release.apk` | только dev |
| `test:flutter-analyze` | Анализ Dart кода | - | dev, MR |
| `test:android-lint` | Android Lint | HTML отчеты | dev, MR |
| `deploy:release` | Публикация релиза | - | только tags (manual) |
### 2. **GitHub Actions** (`.github/workflows/build.yml`)
Альтернативная конфигурация для GitHub:
#### **Workflows:**
| Workflow | Триггер | Описание |
|----------|---------|----------|
| `build-torrent-engine` | push, PR | Сборка AAR модуля |
| `build-debug-apk` | push, PR | Debug APK для тестирования |
| `build-release-apk` | push to dev | Release APK (split-per-abi) |
| `code-quality` | push, PR | Flutter analyze + Android Lint |
---
## ⚙️ Оптимизация RAM
### **gradle.properties**
```properties
# Уменьшено с 4GB до 2GB
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G
# Kotlin daemon с ограничением
kotlin.daemon.jvmargs=-Xmx1G -XX:MaxMetaspaceSize=512m
# Включены оптимизации
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
```
### **CI переменные**
```bash
# В CI используется еще меньше RAM
GRADLE_OPTS="-Xmx1536m -XX:MaxMetaspaceSize=512m"
```
---
## 📦 Артефакты
### **TorrentEngine AAR:**
- Путь: `android/torrentengine/build/outputs/aar/`
- Файл: `torrentengine-release.aar`
- Срок хранения: 7 дней
- Размер: ~5-10 MB
### **Debug APK:**
- Путь: `build/app/outputs/flutter-apk/`
- Файл: `app-debug.apk`
- Срок хранения: 7 дней
- Размер: ~50-80 MB
### **Release APK:**
- Путь: `build/app/outputs/flutter-apk/`
- Файл: `app-arm64-v8a-release.apk`
- Срок хранения: 30 дней
- Размер: ~30-50 MB (split-per-abi)
---
## 🚦 Триггеры сборки
### **GitLab:**
**Автоматически запускается при:**
- Push в `dev` ветку
- Push в `feature/torrent-engine-integration`
- Создание Merge Request
- Push тега (для deploy)
**Ручной запуск:**
- Web UI → Pipelines → Run Pipeline
- Выбрать ветку и нажать "Run pipeline"
### **GitHub:**
**Автоматически запускается при:**
- Push в `dev` или `feature/torrent-engine-integration`
- Pull Request в `dev`
**Ручной запуск:**
- Actions → Build NeoMovies Mobile → Run workflow
---
## 🔧 Настройка GitLab Instance Runners
### **Рекомендуется: Использовать GitLab Instance Runners (SaaS)**
GitLab предоставляет 112+ бесплатных shared runners для всех проектов!
**Как включить:**
1. Перейдите в **Settings → CI/CD → Runners**
2. Найдите секцию **"Instance runners"**
3. Нажмите **"Enable instance runners for this project"**
4. Готово! ✅
**Доступные теги для Instance Runners:**
| Тег | RAM | CPU | Описание |
|-----|-----|-----|----------|
| `saas-linux-small-amd64` | 2 GB | 1 core | Легкие задачи |
| `saas-linux-medium-amd64` | 4 GB | 2 cores | **Рекомендуется для Android** |
| `saas-linux-large-amd64` | 8 GB | 4 cores | Тяжелые сборки |
| `docker` | varies | varies | Любой Docker runner |
**Наша конфигурация использует:**
- TorrentEngine: `saas-linux-medium-amd64` (4GB, 2 cores)
- Остальные jobs: `docker` (автоматический выбор)
---
### **Альтернатива: Локальный Runner (не требуется)**
Только если нужна кастомная конфигурация:
```bash
# 1. Установка GitLab Runner
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt-get install gitlab-runner
# 2. Регистрация Runner
sudo gitlab-runner register \
--url https://gitlab.com/ \
--registration-token YOUR_TOKEN \
--executor docker \
--docker-image mingc/android-build-box:latest \
--tag-list docker,android
# 3. Запуск
sudo gitlab-runner start
```
---
## 📊 Время сборки (примерно)
| Job | Время | RAM | CPU |
|-----|-------|-----|-----|
| TorrentEngine | ~5-10 мин | 1.5GB | 2 cores |
| Debug APK | ~15-20 мин | 2GB | 2 cores |
| Release APK | ~20-30 мин | 2GB | 2 cores |
| Flutter Analyze | ~2-3 мин | 512MB | 1 core |
| Android Lint | ~5-8 мин | 1GB | 2 cores |
---
## 🐳 Docker образы
### **mingc/android-build-box:latest**
Включает:
- Android SDK (latest)
- Flutter SDK
- Java 17
- Gradle
- Git, curl, wget
Размер: ~8GB
---
## 🔍 Кэширование
Для ускорения сборок используется кэширование:
```yaml
cache:
paths:
- .gradle/ # Gradle dependencies
- .pub-cache/ # Flutter packages
- android/.gradle/ # Android build cache
- build/ # Flutter build cache
```
**Эффект:**
- Первая сборка: ~25 минут
- Последующие: ~10-15 минут (с кэшем)
---
## 📝 Логи и отладка
### **Просмотр логов GitLab:**
1. Перейти в **CI/CD → Pipelines**
2. Выбрать pipeline
3. Кликнуть на job для просмотра логов
### **Отладка локально:**
```bash
# Тестирование сборки TorrentEngine
cd android
./gradlew :torrentengine:assembleRelease \
--no-daemon \
--parallel \
--stacktrace
# Тестирование Flutter APK
flutter build apk --debug --verbose
```
---
## 🚨 Troubleshooting
### **Gradle daemon crashed:**
**Проблема:** `Gradle build daemon disappeared unexpectedly`
**Решение:**
```bash
# Увеличить RAM в gradle.properties
org.gradle.jvmargs=-Xmx3G
# Или отключить daemon
./gradlew --no-daemon
```
### **Out of memory:**
**Проблема:** `OutOfMemoryError: Java heap space`
**Решение:**
```bash
# Увеличить heap в CI
GRADLE_OPTS="-Xmx2048m -XX:MaxMetaspaceSize=768m"
```
### **LibTorrent4j native libraries not found:**
**Проблема:** Нативные библиотеки не найдены
**Решение:**
- Убедиться что все архитектуры включены в `build.gradle.kts`
- Проверить `splits.abi` конфигурацию
---
## 📚 Дополнительные ресурсы
- [GitLab CI/CD Docs](https://docs.gitlab.com/ee/ci/)
- [GitHub Actions Docs](https://docs.github.com/actions)
- [Flutter CI/CD Guide](https://docs.flutter.dev/deployment/cd)
- [Gradle Performance](https://docs.gradle.org/current/userguide/performance.html)
---
## 🎯 Следующие шаги
1. **Настроить GitLab Runner** (если еще не настроен)
2. **Запушить изменения** в dev ветку
3. **Проверить Pipeline** в GitLab CI/CD
4. **Скачать артефакты** после успешной сборки
5. **Протестировать APK** на реальном устройстве
---
## 📞 Поддержка
При проблемах с CI/CD:
1. Проверьте логи pipeline
2. Убедитесь что Runner активен
3. Проверьте доступность Docker образа
4. Создайте issue с логами ошибки
---
**Создано с ❤️ для NeoMovies Mobile**

View File

@@ -1,408 +0,0 @@
# 📝 Development Summary - NeoMovies Mobile
## 🎯 Выполненные задачи
### 1. ⚡ Торрент Движок (TorrentEngine Library)
Создана **полноценная библиотека для работы с торрентами** как отдельный модуль Android:
#### 📦 Структура модуля:
```
android/torrentengine/
├── build.gradle.kts # Конфигурация с LibTorrent4j
├── proguard-rules.pro # ProGuard правила
├── consumer-rules.pro # Consumer ProGuard rules
├── README.md # Подробная документация
└── src/main/
├── AndroidManifest.xml # Permissions и Service
└── java/com/neomovies/torrentengine/
├── TorrentEngine.kt # Главный API класс
├── models/
│ └── TorrentInfo.kt # Модели данных (TorrentInfo, TorrentFile, etc.)
├── database/
│ ├── TorrentDao.kt # Room DAO
│ ├── TorrentDatabase.kt
│ └── Converters.kt # Type converters
└── service/
└── TorrentService.kt # Foreground service
```
#### ✨ Возможности TorrentEngine:
1. **Загрузка из magnet-ссылок**
- Автоматическое получение метаданных
- Парсинг файлов и их размеров
- Поддержка DHT и LSD
2. **Управление файлами**
- Выбор файлов ДО начала загрузки
- Изменение приоритетов В ПРОЦЕССЕ загрузки
- Фильтрация по типу (видео, аудио и т.д.)
- 5 уровней приоритета: DONT_DOWNLOAD, LOW, NORMAL, HIGH, MAXIMUM
3. **Foreground Service с уведомлением**
- Постоянное уведомление (не удаляется пока активны торренты)
- Отображение скорости загрузки/отдачи
- Список активных торрентов с прогрессом
- Кнопки управления (Pause All)
4. **Персистентность (Room Database)**
- Автоматическое сохранение состояния
- Восстановление торрентов после перезагрузки
- Реактивные Flow для мониторинга изменений
5. **Полная статистика**
- Скорость загрузки/отдачи (real-time)
- Количество пиров и сидов
- Прогресс загрузки (%)
- ETA (время до завершения)
- Share ratio (отдано/скачано)
6. **Контроль раздач**
- `addTorrent()` - добавить торрент
- `pauseTorrent()` - поставить на паузу
- `resumeTorrent()` - возобновить
- `removeTorrent()` - удалить (с файлами или без)
- `setFilePriority()` - изменить приоритет файла
- `setFilePriorities()` - массовое изменение приоритетов
#### 📚 Использование:
```kotlin
// Инициализация
val torrentEngine = TorrentEngine.getInstance(context)
torrentEngine.startStatsUpdater()
// Добавление торрента
val infoHash = torrentEngine.addTorrent(magnetUri, savePath)
// Мониторинг (реактивно)
torrentEngine.getAllTorrentsFlow().collect { torrents ->
torrents.forEach { torrent ->
println("${torrent.name}: ${torrent.progress * 100}%")
}
}
// Изменение приоритетов файлов
torrent.files.forEachIndexed { index, file ->
if (file.isVideo()) {
torrentEngine.setFilePriority(infoHash, index, FilePriority.HIGH)
}
}
// Управление
torrentEngine.pauseTorrent(infoHash)
torrentEngine.resumeTorrent(infoHash)
torrentEngine.removeTorrent(infoHash, deleteFiles = true)
```
### 2. 🔄 Новый API Client (NeoMoviesApiClient)
Полностью переписан API клиент для работы с **новым Go-based бэкендом (neomovies-api)**:
#### 📍 Файл: `lib/data/api/neomovies_api_client.dart`
#### 🆕 Новые возможности:
**Аутентификация:**
-`register()` - регистрация с отправкой кода на email
-`verifyEmail()` - подтверждение email кодом
-`resendVerificationCode()` - повторная отправка кода
-`login()` - вход по email/password
-`getGoogleOAuthUrl()` - URL для Google OAuth
-`refreshToken()` - обновление JWT токена
-`getProfile()` - получение профиля
-`deleteAccount()` - удаление аккаунта
**Фильмы:**
-`getPopularMovies()` - популярные фильмы
-`getTopRatedMovies()` - топ рейтинг
-`getUpcomingMovies()` - скоро выйдут
-`getNowPlayingMovies()` - сейчас в кино
-`getMovieById()` - детали фильма
-`getMovieRecommendations()` - рекомендации
-`searchMovies()` - поиск фильмов
**Сериалы:**
-`getPopularTvShows()` - популярные сериалы
-`getTopRatedTvShows()` - топ сериалы
-`getTvShowById()` - детали сериала
-`getTvShowRecommendations()` - рекомендации
-`searchTvShows()` - поиск сериалов
**Избранное:**
-`getFavorites()` - список избранного
-`addFavorite()` - добавить в избранное
-`removeFavorite()` - удалить из избранного
**Реакции (новое!):**
-`getReactionCounts()` - количество лайков/дизлайков
-`setReaction()` - поставить like/dislike
-`getMyReactions()` - мои реакции
**Торренты (новое!):**
-`searchTorrents()` - поиск торрентов через RedAPI
- По IMDb ID
- Фильтры: quality, season, episode
- Поддержка фильмов и сериалов
**Плееры (новое!):**
-`getAllohaPlayer()` - Alloha embed URL
-`getLumexPlayer()` - Lumex embed URL
-`getVibixPlayer()` - Vibix embed URL
#### 🔧 Пример использования:
```dart
final apiClient = NeoMoviesApiClient(http.Client());
// Регистрация с email verification
await apiClient.register(
email: 'user@example.com',
password: 'password123',
name: 'John Doe',
);
// Подтверждение кода
final authResponse = await apiClient.verifyEmail(
email: 'user@example.com',
code: '123456',
);
// Поиск торрентов
final torrents = await apiClient.searchTorrents(
imdbId: 'tt1234567',
type: 'movie',
quality: '1080p',
);
// Получить плеер
final player = await apiClient.getAllohaPlayer('tt1234567');
```
### 3. 📊 Новые модели данных
Созданы модели для новых фич:
#### `PlayerResponse` (`lib/data/models/player/player_response.dart`):
```dart
class PlayerResponse {
final String? embedUrl;
final String? playerType;
final String? error;
}
```
### 4. 📖 Документация
Создана подробная документация:
- **`android/torrentengine/README.md`** - полное руководство по TorrentEngine
- Описание всех возможностей
- Примеры использования
- API reference
- Интеграция с Flutter
- Известные проблемы
---
## 🚀 Что готово к использованию
### ✅ TorrentEngine Library
- Полностью функциональный торрент движок
- Можно использовать как отдельную библиотеку
- Готов к интеграции с Flutter через MethodChannel
- Все основные функции реализованы
### ✅ NeoMoviesApiClient
- Полная поддержка нового API
- Все endpoints реализованы
- Готов к замене старого ApiClient
### ✅ База для дальнейшей разработки
- Структура модуля torrentengine создана
- Build конфигурация готова
- ProGuard правила настроены
- Permissions объявлены
---
## 📋 Следующие шаги
### 1. Интеграция TorrentEngine с Flutter
Создать MethodChannel в `MainActivity.kt`:
```kotlin
class MainActivity: FlutterActivity() {
private val TORRENT_CHANNEL = "com.neomovies/torrent"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val torrentEngine = TorrentEngine.getInstance(applicationContext)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TORRENT_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"addTorrent" -> {
val magnetUri = call.argument<String>("magnetUri")!!
val savePath = call.argument<String>("savePath")!!
CoroutineScope(Dispatchers.IO).launch {
try {
val hash = torrentEngine.addTorrent(magnetUri, savePath)
withContext(Dispatchers.Main) {
result.success(hash)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("ERROR", e.message, null)
}
}
}
}
"getTorrents" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val torrents = torrentEngine.getAllTorrents()
val torrentsJson = torrents.map { /* convert to map */ }
withContext(Dispatchers.Main) {
result.success(torrentsJson)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("ERROR", e.message, null)
}
}
}
}
// ... другие методы
}
}
}
}
```
Создать Dart wrapper:
```dart
class TorrentEngineService {
static const platform = MethodChannel('com.neomovies/torrent');
Future<String> addTorrent(String magnetUri, String savePath) async {
return await platform.invokeMethod('addTorrent', {
'magnetUri': magnetUri,
'savePath': savePath,
});
}
Future<List<Map<String, dynamic>>> getTorrents() async {
final List<dynamic> result = await platform.invokeMethod('getTorrents');
return result.cast<Map<String, dynamic>>();
}
}
```
### 2. Замена старого API клиента
В файлах сервисов и репозиториев заменить:
```dart
// Старое
final apiClient = ApiClient(http.Client());
// Новое
final apiClient = NeoMoviesApiClient(http.Client());
```
### 3. Создание UI для новых фич
**Email Verification Screen:**
- Ввод кода подтверждения
- Кнопка "Отправить код повторно"
- Таймер обратного отсчета
**Torrent List Screen:**
- Список активных торрентов
- Прогресс бар для каждого
- Скорость загрузки/отдачи
- Кнопки pause/resume/delete
**File Selection Screen:**
- Список файлов в торренте
- Checkbox для выбора файлов
- Slider для приоритета
- Отображение размера файлов
**Player Selection Screen:**
- Выбор плеера (Alloha/Lumex/Vibix)
- WebView для отображения плеера
**Reactions UI:**
- Кнопки like/dislike
- Счетчики реакций
- Анимации при клике
### 4. Тестирование
1. **Компиляция проекта:**
```bash
cd neomovies_mobile
flutter pub get
flutter build apk --debug
```
2. **Тестирование TorrentEngine:**
- Добавление magnet-ссылки
- Получение метаданных
- Выбор файлов
- Изменение приоритетов в процессе загрузки
- Проверка уведомления
- Pause/Resume/Delete
3. **Тестирование API:**
- Регистрация и email verification
- Логин
- Поиск торрентов
- Получение плееров
- Реакции
---
## 💡 Преимущества нового решения
### TorrentEngine:
✅ Отдельная библиотека - можно использовать в других проектах
✅ LibTorrent4j - надежный и производительный
✅ Foreground service - стабильная работа в фоне
✅ Room database - надежное хранение состояния
✅ Flow API - реактивные обновления UI
✅ Полный контроль - все функции доступны
### NeoMoviesApiClient:
✅ Go backend - в 3x быстрее Node.js
✅ Меньше потребление памяти - 50% экономия
✅ Email verification - безопасная регистрация
✅ Google OAuth - удобный вход
✅ Торрент поиск - интеграция с RedAPI
✅ Множество плееров - выбор для пользователя
✅ Реакции - вовлечение пользователей
---
## 🎉 Итоги
**Создано:**
- ✅ Полноценная библиотека TorrentEngine (700+ строк кода)
- ✅ Новый API клиент NeoMoviesApiClient (450+ строк)
- ✅ Модели данных для новых фич
- ✅ Подробная документация
- ✅ ProGuard правила
- ✅ Готовая структура для интеграции
**Готово к:**
- ⚡ Компиляции и тестированию
- 📱 Интеграции с Flutter
- 🚀 Деплою в production
**Следующий шаг:**
Интеграция TorrentEngine с Flutter через MethodChannel и создание UI для торрент менеджера.

View File

@@ -1,201 +0,0 @@
# 🚀 Add TorrentEngine Library and New API Client
## 📝 Описание
Полная реализация торрент движка на Kotlin с использованием LibTorrent4j и интеграция с Flutter приложением через MethodChannel. Также добавлен новый API клиент для работы с обновленным Go-based бэкендом.
---
## ✨ Новые возможности
### 1. **TorrentEngine Library** (Kotlin)
Полноценный торрент движок как отдельный модуль Android:
#### 🎯 **Основные функции:**
- ✅ Загрузка из magnet-ссылок с автоматическим извлечением метаданных
- ✅ Выбор файлов ДО и ВО ВРЕМЯ загрузки
- ✅ Управление приоритетами файлов (5 уровней: DONT_DOWNLOAD → MAXIMUM)
- ✅ Foreground Service с постоянным уведомлением
- ✅ Room Database для персистентности состояния
- ✅ Реактивные Flow API для мониторинга изменений
- ✅ Полная статистика (скорость, пиры, сиды, прогресс, ETA)
- ✅ Pause/Resume/Remove с опциональным удалением файлов
#### 📦 **Структура модуля:**
```
android/torrentengine/
├── TorrentEngine.kt # Главный API класс (500+ строк)
├── TorrentService.kt # Foreground service с уведомлением
├── models/TorrentInfo.kt # Модели данных
├── database/ # Room DAO и Database
│ ├── TorrentDao.kt
│ ├── TorrentDatabase.kt
│ └── Converters.kt
├── build.gradle.kts # LibTorrent4j dependencies
├── AndroidManifest.xml # Permissions и Service
├── README.md # Полная документация
└── proguard-rules.pro # ProGuard правила
```
#### 🔧 **Использование:**
```kotlin
val engine = TorrentEngine.getInstance(context)
val hash = engine.addTorrent(magnetUri, savePath)
engine.setFilePriority(hash, fileIndex, FilePriority.HIGH)
engine.pauseTorrent(hash)
engine.resumeTorrent(hash)
engine.removeTorrent(hash, deleteFiles = true)
```
### 2. **MethodChannel Integration** (Kotlin ↔ Flutter)
Полная интеграция TorrentEngine с Flutter через MethodChannel в `MainActivity.kt`:
#### 📡 **Доступные методы:**
- `addTorrent(magnetUri, savePath)` → infoHash
- `getTorrents()` → List<TorrentInfo> (JSON)
- `getTorrent(infoHash)` → TorrentInfo (JSON)
- `pauseTorrent(infoHash)` → success
- `resumeTorrent(infoHash)` → success
- `removeTorrent(infoHash, deleteFiles)` → success
- `setFilePriority(infoHash, fileIndex, priority)` → success
### 3. **NeoMoviesApiClient** (Dart)
Новый API клиент для работы с Go-based бэкендом:
#### 🆕 **Новые endpoints:**
**Аутентификация:**
- Email verification flow (register → verify → login)
- Google OAuth URL
- Token refresh
**Торренты:**
- Поиск через RedAPI по IMDb ID
- Фильтры по качеству, сезону, эпизоду
**Плееры:**
- Alloha, Lumex, Vibix embed URLs
**Реакции:**
- Лайки/дизлайки
- Счетчики реакций
- Мои реакции
---
## 🔄 Измененные файлы
### Android:
- `android/settings.gradle.kts` - добавлен модуль `:torrentengine`
- `android/app/build.gradle.kts` - обновлены зависимости, Java 17
- `android/app/src/main/kotlin/.../MainActivity.kt` - интеграция TorrentEngine
### Flutter:
- `pubspec.yaml` - исправлен конфликт `build_runner`
- `lib/data/api/neomovies_api_client.dart` - новый API клиент (450+ строк)
- `lib/data/models/player/player_response.dart` - модель ответа плеера
### Документация:
- `android/torrentengine/README.md` - подробная документация по TorrentEngine
- `DEVELOPMENT_SUMMARY.md` - полный отчет о проделанной работе
---
## 🏗️ Технические детали
### Зависимости:
**TorrentEngine:**
- LibTorrent4j 2.1.0-28 (arm64, arm, x86, x86_64)
- Room 2.6.1
- Kotlin Coroutines 1.9.0
- Gson 2.11.0
**App:**
- Обновлен Java до версии 17
- Обновлены AndroidX библиотеки
- Исправлен конфликт build_runner (2.4.13)
### Permissions:
- INTERNET, ACCESS_NETWORK_STATE
- WRITE/READ_EXTERNAL_STORAGE
- MANAGE_EXTERNAL_STORAGE (Android 11+)
- FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC
- POST_NOTIFICATIONS
- WAKE_LOCK
---
## ✅ Что работает
**Структура TorrentEngine модуля создана**
**LibTorrent4j интегрирован**
**Room database настроена**
**Foreground Service реализован**
**MethodChannel для Flutter готов**
**Новый API клиент написан**
**Все файлы закоммичены и запушены**
---
## 📋 Следующие шаги
### Для полного завершения требуется:
1. **Сборка APK** - необходима более мощная среда для полной компиляции с LibTorrent4j
2. **Flutter интеграция** - создать Dart wrapper для MethodChannel
3. **UI для торрентов** - экраны списка торрентов, выбора файлов
4. **Тестирование** - проверка работы на реальном устройстве
### Дополнительно:
- Исправить ошибки анализатора Dart (отсутствующие модели плеера)
- Сгенерировать код для `player_response.g.dart`
- Добавить модель `TorrentItem` для API клиента
---
## 📊 Статистика
- **Создано файлов:** 16
- **Изменено файлов:** 4
- **Добавлено строк кода:** ~2700+
- **Kotlin код:** ~1500 строк
- **Dart код:** ~500 строк
- **Документация:** ~700 строк
---
## 🎉 Итоги
Создана **полноценная библиотека для работы с торрентами**, которая:
- Может использоваться как отдельный модуль в любых Android проектах
- Предоставляет все необходимые функции для торрент-клиента
- Интегрирована с Flutter через MethodChannel
- Имеет подробную документацию с примерами
Также создан **новый API клиент** для работы с обновленным бэкендом с поддержкой новых фич:
- Email verification
- Google OAuth
- Torrent search
- Multiple players
- Reactions system
---
## 🔗 Ссылки
- **Branch:** `feature/torrent-engine-integration`
- **Commit:** 1b28c5d
- **Документация:** `android/torrentengine/README.md`
- **Отчет:** `DEVELOPMENT_SUMMARY.md`
---
## 👤 Author
**Droid (Factory AI Assistant)**
Создано с использованием LibTorrent4j, Room, Kotlin Coroutines, и Flutter MethodChannel.

View File

@@ -2,6 +2,8 @@
Мобильное приложение для просмотра фильмов и сериалов, созданное на Flutter.
[![Download](https://img.shields.io/github/v/release/Neo-Open-Source/neomovies-mobile?label=Download&style=for-the-badge&logo=github)](https://github.com/Neo-Open-Source/neomovies-mobile/releases/latest)
## Возможности
- 📱 Кроссплатформенное приложение (Android/iOS(пока не реализовано))

View File

@@ -1,7 +1,7 @@
# Gradle JVM settings - optimized for limited RAM
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.parallel=false
org.gradle.caching=true
org.gradle.configureondemand=true

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 NeoMovies
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,20 +1,8 @@
# TorrentEngine Library
Мощная библиотека для Android, обеспечивающая полноценную работу с торрентами через LibTorrent4j.
Либа для моего клиента и других независимых проектов где нужен простой торрент движок.
## 🎯 Возможности
-**Загрузка из magnet-ссылок** - получение метаданных и загрузка файлов
-**Выбор файлов** - возможность выбирать какие файлы загружать до и во время загрузки
-**Управление приоритетами** - изменение приоритета файлов в активной раздаче
-**Фоновый сервис** - непрерывная работа в фоне с foreground уведомлением
-**Постоянное уведомление** - нельзя закрыть пока активны загрузки
-**Персистентность** - сохранение состояния в Room database
-**Реактивность** - Flow API для мониторинга изменений
-**Полная статистика** - скорость, пиры, сиды, прогресс, ETA
-**Pause/Resume/Remove** - полный контроль над раздачами
## 📦 Установка
## Установка
### 1. Добавьте модуль в `settings.gradle.kts`:
@@ -38,7 +26,7 @@ dependencies {
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
## 🚀 Использование
## Использование
### Инициализация
@@ -127,7 +115,7 @@ lifecycleScope.launch {
}
```
## 📊 Модели данных
## Модели данных
### TorrentInfo
@@ -180,7 +168,7 @@ enum class FilePriority(val value: Int) {
}
```
## 🔔 Foreground Service
## Foreground Service
Сервис автоматически запускается при добавлении торрента и показывает постоянное уведомление с:
- Количеством активных торрентов
@@ -190,12 +178,10 @@ enum class FilePriority(val value: Int) {
Уведомление **нельзя закрыть** пока есть активные торренты.
## 💾 Персистентность
## Персистентность
Все торренты сохраняются в Room database и автоматически восстанавливаются при перезапуске приложения.
## 🔧 Расширенные возможности
### Проверка видео файлов
```kotlin
@@ -215,54 +201,6 @@ val selectedCount = torrent.getSelectedFilesCount()
val selectedSize = torrent.getSelectedSize()
```
## 📱 Интеграция с Flutter
[Apache License 2.0](LICENSE).
Создайте MethodChannel для вызова из Flutter:
```kotlin
class TorrentEngineChannel(private val context: Context) {
private val torrentEngine = TorrentEngine.getInstance(context)
private val channel = "com.neomovies/torrent"
fun setupMethodChannel(flutterEngine: FlutterEngine) {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result ->
when (call.method) {
"addTorrent" -> {
val magnetUri = call.argument<String>("magnetUri")!!
val savePath = call.argument<String>("savePath")!!
CoroutineScope(Dispatchers.IO).launch {
try {
val hash = torrentEngine.addTorrent(magnetUri, savePath)
result.success(hash)
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}
}
// ... другие методы
}
}
}
}
```
## 📄 Лицензия
MIT License - используйте свободно в любых проектах!
## 🤝 Вклад
Библиотека разработана как универсальное решение для работы с торрентами в Android.
Может использоваться в любых проектах без ограничений.
## 🐛 Известные проблемы
- LibTorrent4j требует минимум Android 5.0 (API 21)
- Для Android 13+ нужно запрашивать POST_NOTIFICATIONS permission
- Foreground service требует отображения уведомления
## 📞 Поддержка
При возникновении проблем создайте issue с описанием и логами.
Made with <3 by Erno/Foxix

View File

@@ -16,13 +16,7 @@ import java.io.File
/**
* Main TorrentEngine class - the core of the torrent library
* This is the main API that applications should use
*
* Usage:
* ```
* val engine = TorrentEngine.getInstance(context)
* engine.addTorrent(magnetUri, savePath)
* ```
* This is the main API that applications should use.
*/
class TorrentEngine private constructor(private val context: Context) {
private val TAG = "TorrentEngine"

View File

@@ -1,332 +1,145 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:neomovies_mobile/data/models/auth_response.dart';
import 'package:neomovies_mobile/data/models/favorite.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/models/favorite.dart';
import 'package:neomovies_mobile/data/models/reaction.dart';
import 'package:neomovies_mobile/data/models/auth_response.dart';
import 'package:neomovies_mobile/data/models/user.dart';
import 'package:neomovies_mobile/data/api/neomovies_api_client.dart'; // новый клиент
import 'package:neomovies_mobile/data/exceptions/auth_exceptions.dart';
class ApiClient {
final http.Client _client;
final String _baseUrl = dotenv.env['API_URL']!;
final NeoMoviesApiClient _neoClient;
ApiClient(this._client);
ApiClient(http.Client client)
: _neoClient = NeoMoviesApiClient(client);
Future<List<Movie>> getPopularMovies({int page = 1}) async {
return _fetchMovies('/movies/popular', page: page);
// ---- Movies ----
Future<List<Movie>> getPopularMovies({int page = 1}) {
return _neoClient.getPopularMovies(page: page);
}
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
return _fetchMovies('/movies/top-rated', page: page);
Future<List<Movie>> getTopRatedMovies({int page = 1}) {
return _neoClient.getTopRatedMovies(page: page);
}
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
return _fetchMovies('/movies/upcoming', page: page);
Future<List<Movie>> getUpcomingMovies({int page = 1}) {
return _neoClient.getUpcomingMovies(page: page);
}
Future<Movie> getMovieById(String id) async {
return _fetchMovieDetail('/movies/$id');
Future<Movie> getMovieById(String id) {
return _neoClient.getMovieById(id);
}
Future<Movie> getTvById(String id) async {
return _fetchMovieDetail('/tv/$id');
Future<Movie> getTvById(String id) {
return _neoClient.getTvShowById(id);
}
// Получение IMDB ID для фильмов
Future<String?> getMovieImdbId(int movieId) async {
// ---- Search ----
Future<List<Movie>> searchMovies(String query, {int page = 1}) {
return _neoClient.search(query, page: page);
}
// ---- Favorites ----
Future<List<Favorite>> getFavorites() {
return _neoClient.getFavorites();
}
Future<void> addFavorite(
String mediaId,
String mediaType,
String title,
String posterPath,
) {
return _neoClient.addFavorite(
mediaId: mediaId,
mediaType: mediaType,
title: title,
posterPath: posterPath,
);
}
Future<void> removeFavorite(String mediaId, {String mediaType = 'movie'}) {
return _neoClient.removeFavorite(mediaId, mediaType: mediaType);
}
Future<bool> checkIsFavorite(String mediaId, {String mediaType = 'movie'}) {
return _neoClient.checkIsFavorite(mediaId, mediaType: mediaType);
}
// ---- Reactions ----
Future<Map<String, int>> getReactionCounts(
String mediaType, String mediaId) {
return _neoClient.getReactionCounts(
mediaType: mediaType,
mediaId: mediaId,
);
}
Future<void> setReaction(
String mediaType, String mediaId, String reactionType) {
return _neoClient.setReaction(
mediaType: mediaType,
mediaId: mediaId,
reactionType: reactionType,
);
}
Future<List<UserReaction>> getMyReactions() {
return _neoClient.getMyReactions();
}
// Get single user reaction for specific media
Future<UserReaction?> getMyReaction(String mediaType, String mediaId) async {
final reactions = await _neoClient.getMyReactions();
try {
final uri = Uri.parse('$_baseUrl/movies/$movieId/external-ids');
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['imdb_id'] as String?;
} else {
print('Failed to get movie IMDB ID: ${response.statusCode}');
return null;
}
return reactions.firstWhere(
(r) => r.mediaType == mediaType && r.mediaId == mediaId,
);
} catch (e) {
print('Error getting movie IMDB ID: $e');
return null;
return null; // No reaction found
}
}
// Получение IMDB ID для сериалов
Future<String?> getTvImdbId(int showId) async {
try {
final uri = Uri.parse('$_baseUrl/tv/$showId/external-ids');
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['imdb_id'] as String?;
} else {
print('Failed to get TV IMDB ID: ${response.statusCode}');
return null;
}
} catch (e) {
print('Error getting TV IMDB ID: $e');
return null;
}
// ---- External IDs (IMDb) ----
Future<String?> getImdbId(String mediaId, String mediaType) async {
// This would need to be implemented in NeoMoviesApiClient
// For now, return null or implement a stub
// TODO: Add getExternalIds endpoint to backend
return null;
}
// Универсальный метод получения IMDB ID
Future<String?> getImdbId(int mediaId, String mediaType) async {
if (mediaType == 'tv') {
return getTvImdbId(mediaId);
} else {
return getMovieImdbId(mediaId);
}
}
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
final moviesUri = Uri.parse('$_baseUrl/movies/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
final tvUri = Uri.parse('$_baseUrl/tv/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
final responses = await Future.wait([
_client.get(moviesUri),
_client.get(tvUri),
]);
List<Movie> combined = [];
for (final response in responses) {
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
List<dynamic> listData;
if (decoded is List) {
listData = decoded;
} else if (decoded is Map && decoded['results'] is List) {
listData = decoded['results'];
} else {
listData = [];
}
combined.addAll(listData.map((json) => Movie.fromJson(json)));
} else {
// ignore non-200 but log maybe
}
}
if (combined.isEmpty) {
throw Exception('Failed to search movies/tv');
}
return combined;
}
Future<Movie> _fetchMovieDetail(String path) async {
final uri = Uri.parse('$_baseUrl$path');
final response = await _client.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return Movie.fromJson(data);
} else {
throw Exception('Failed to load media details: ${response.statusCode}');
}
}
// Favorites
Future<List<Favorite>> getFavorites() async {
final response = await _client.get(Uri.parse('$_baseUrl/favorites'));
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
return data.map((json) => Favorite.fromJson(json)).toList();
} else {
throw Exception('Failed to fetch favorites');
}
}
Future<void> addFavorite(String mediaId, String mediaType, String title, String posterPath) async {
final response = await _client.post(
Uri.parse('$_baseUrl/favorites/$mediaId?mediaType=$mediaType'),
body: json.encode({
'title': title,
'posterPath': posterPath,
}),
);
if (response.statusCode != 201 && response.statusCode != 200) {
throw Exception('Failed to add favorite');
}
}
Future<void> removeFavorite(String mediaId) async {
final response = await _client.delete(
Uri.parse('$_baseUrl/favorites/$mediaId'),
);
if (response.statusCode != 200) {
throw Exception('Failed to remove favorite');
}
}
// Reactions
Future<Map<String, int>> getReactionCounts(String mediaType, String mediaId) async {
final response = await _client.get(
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/counts'),
);
print('REACTION COUNTS RESPONSE (${response.statusCode}): ${response.body}');
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
print('PARSED: $decoded');
if (decoded is Map) {
final mapSrc = decoded.containsKey('data') && decoded['data'] is Map
? decoded['data'] as Map<String, dynamic>
: decoded;
print('MAPPING: $mapSrc');
return mapSrc.map((k, v) {
int count;
if (v is num) {
count = v.toInt();
} else if (v is String) {
count = int.tryParse(v) ?? 0;
} else {
count = 0;
}
return MapEntry(k, count);
});
}
if (decoded is List) {
// list of {type,count}
Map<String, int> res = {};
for (var item in decoded) {
if (item is Map && item['type'] != null) {
res[item['type'].toString()] = (item['count'] as num?)?.toInt() ?? 0;
}
}
return res;
}
return {};
} else {
throw Exception('Failed to fetch reactions counts');
}
}
Future<UserReaction> getMyReaction(String mediaType, String mediaId) async {
final response = await _client.get(
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/my-reaction'),
);
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
if (decoded == null || (decoded is String && decoded.isEmpty)) {
return UserReaction(reactionType: null);
}
return UserReaction.fromJson(decoded as Map<String, dynamic>);
} else if (response.statusCode == 404) {
return UserReaction(reactionType: 'none'); // No reaction found
} else {
throw Exception('Failed to fetch user reaction');
}
}
Future<void> setReaction(String mediaType, String mediaId, String reactionType) async {
final response = await _client.post(
Uri.parse('$_baseUrl/reactions'),
headers: {'Content-Type': 'application/json'},
body: json.encode({'mediaId': '${mediaType}_${mediaId}', 'type': reactionType}),
);
if (response.statusCode != 201 && response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Failed to set reaction: ${response.statusCode} ${response.body}');
}
}
// --- Auth Methods ---
Future<void> register(String name, String email, String password) async {
final uri = Uri.parse('$_baseUrl/auth/register');
final response = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode({'name': name, 'email': email, 'password': password}),
);
if (response.statusCode == 201 || response.statusCode == 200) {
final decoded = json.decode(response.body) as Map<String, dynamic>;
if (decoded['success'] == true || decoded.containsKey('token')) {
// registration succeeded; nothing further to return
return;
} else {
throw Exception('Failed to register: ${decoded['message'] ?? 'Unknown error'}');
}
} else {
throw Exception('Failed to register: ${response.statusCode} ${response.body}');
}
// ---- Auth ----
Future<void> register(String name, String email, String password) {
return _neoClient.register(
name: name,
email: email,
password: password,
).then((_) {}); // старый код ничего не возвращал
}
Future<AuthResponse> login(String email, String password) async {
final uri = Uri.parse('$_baseUrl/auth/login');
final response = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
return AuthResponse.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to login: ${response.body}');
}
}
Future<void> verify(String email, String code) async {
final uri = Uri.parse('$_baseUrl/auth/verify');
final response = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode({'email': email, 'code': code}),
);
if (response.statusCode != 200) {
throw Exception('Failed to verify code: ${response.body}');
}
}
Future<void> resendCode(String email) async {
final uri = Uri.parse('$_baseUrl/auth/resend-code');
final response = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode({'email': email}),
);
if (response.statusCode != 200) {
throw Exception('Failed to resend code: ${response.body}');
}
}
Future<void> deleteAccount() async {
final uri = Uri.parse('$_baseUrl/auth/profile');
final response = await _client.delete(uri);
if (response.statusCode != 200) {
throw Exception('Failed to delete account: ${response.body}');
}
}
// --- Movie Methods ---
Future<List<Movie>> _fetchMovies(String endpoint, {int page = 1}) async {
final uri = Uri.parse('$_baseUrl$endpoint').replace(queryParameters: {
'page': page.toString(),
});
final response = await _client.get(uri);
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body)['results'];
if (data == null) {
return [];
try {
return await _neoClient.login(email: email, password: password);
} catch (e) {
final errorMessage = e.toString();
if (errorMessage.contains('Account not activated') ||
errorMessage.contains('not verified') ||
errorMessage.contains('Please verify your email')) {
throw UnverifiedAccountException(email, message: errorMessage);
}
return data.map((json) => Movie.fromJson(json)).toList();
} else {
throw Exception('Failed to load movies from $endpoint');
rethrow;
}
}
}
Future<AuthResponse> verify(String email, String code) {
return _neoClient.verifyEmail(email: email, code: code);
}
Future<void> resendCode(String email) {
return _neoClient.resendVerificationCode(email);
}
Future<void> deleteAccount() {
return _neoClient.deleteAccount();
}
}

View File

@@ -7,6 +7,7 @@ import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/models/reaction.dart';
import 'package:neomovies_mobile/data/models/user.dart';
import 'package:neomovies_mobile/data/models/torrent.dart';
import 'package:neomovies_mobile/data/models/torrent/torrent_item.dart';
import 'package:neomovies_mobile/data/models/player/player_response.dart';
/// New API client for neomovies-api (Go-based backend)
@@ -188,7 +189,12 @@ class NeoMoviesApiClient {
final response = await _client.get(uri);
if (response.statusCode == 200) {
return Movie.fromJson(json.decode(response.body));
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": {...}}
final movieData = (apiResponse is Map && apiResponse['data'] != null)
? apiResponse['data']
: apiResponse;
return Movie.fromJson(movieData);
} else {
throw Exception('Failed to load movie: ${response.statusCode}');
}
@@ -224,7 +230,12 @@ class NeoMoviesApiClient {
final response = await _client.get(uri);
if (response.statusCode == 200) {
return Movie.fromJson(json.decode(response.body));
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": {...}}
final tvData = (apiResponse is Map && apiResponse['data'] != null)
? apiResponse['data']
: apiResponse;
return Movie.fromJson(tvData);
} else {
throw Exception('Failed to load TV show: ${response.statusCode}');
}
@@ -265,7 +276,11 @@ class NeoMoviesApiClient {
final response = await _client.get(uri);
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": [...]}
final List<dynamic> data = (apiResponse is Map && apiResponse['data'] != null)
? (apiResponse['data'] is List ? apiResponse['data'] : [])
: (apiResponse is List ? apiResponse : []);
return data.map((json) => Favorite.fromJson(json)).toList();
} else {
throw Exception('Failed to fetch favorites: ${response.body}');
@@ -273,23 +288,17 @@ class NeoMoviesApiClient {
}
/// Add movie/show to favorites
/// Backend automatically fetches title and poster_path from TMDB
Future<void> addFavorite({
required String mediaId,
required String mediaType,
required String title,
required String posterPath,
}) async {
final uri = Uri.parse('$apiUrl/favorites');
final response = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode({
'mediaId': mediaId,
'mediaType': mediaType,
'title': title,
'posterPath': posterPath,
}),
);
// Backend route: POST /favorites/{id}?type={mediaType}
final uri = Uri.parse('$apiUrl/favorites/$mediaId')
.replace(queryParameters: {'type': mediaType});
final response = await _client.post(uri);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception('Failed to add favorite: ${response.body}');
@@ -297,8 +306,10 @@ class NeoMoviesApiClient {
}
/// Remove movie/show from favorites
Future<void> removeFavorite(String mediaId) async {
final uri = Uri.parse('$apiUrl/favorites/$mediaId');
Future<void> removeFavorite(String mediaId, {String mediaType = 'movie'}) async {
// Backend route: DELETE /favorites/{id}?type={mediaType}
final uri = Uri.parse('$apiUrl/favorites/$mediaId')
.replace(queryParameters: {'type': mediaType});
final response = await _client.delete(uri);
if (response.statusCode != 200 && response.statusCode != 204) {
@@ -306,6 +317,26 @@ class NeoMoviesApiClient {
}
}
/// Check if media is in favorites
Future<bool> checkIsFavorite(String mediaId, {String mediaType = 'movie'}) async {
// Backend route: GET /favorites/{id}/check?type={mediaType}
final uri = Uri.parse('$apiUrl/favorites/$mediaId/check')
.replace(queryParameters: {'type': mediaType});
final response = await _client.get(uri);
if (response.statusCode == 200) {
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": {"isFavorite": true}}
if (apiResponse is Map && apiResponse['data'] != null) {
final data = apiResponse['data'];
return data['isFavorite'] ?? false;
}
return false;
} else {
throw Exception('Failed to check favorite status: ${response.body}');
}
}
// ============================================
// Reactions Endpoints (NEW!)
// ============================================
@@ -350,7 +381,11 @@ class NeoMoviesApiClient {
final response = await _client.get(uri);
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": [...]}
final List<dynamic> data = (apiResponse is Map && apiResponse['data'] != null)
? (apiResponse['data'] is List ? apiResponse['data'] : [])
: (apiResponse is List ? apiResponse : []);
return data.map((json) => UserReaction.fromJson(json)).toList();
} else {
throw Exception('Failed to get my reactions: ${response.body}');
@@ -432,8 +467,18 @@ class NeoMoviesApiClient {
if (response.statusCode == 200) {
final decoded = json.decode(response.body);
// API returns: {"success": true, "data": {"page": 1, "results": [...], ...}}
List<dynamic> results;
if (decoded is List) {
if (decoded is Map && decoded['success'] == true && decoded['data'] != null) {
final data = decoded['data'];
if (data is Map && data['results'] != null) {
results = data['results'];
} else if (data is List) {
results = data;
} else {
throw Exception('Unexpected data format in API response');
}
} else if (decoded is List) {
results = decoded;
} else if (decoded is Map && decoded['results'] != null) {
results = decoded['results'];

View File

@@ -1,11 +1,12 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
class Favorite {
final int id;
final String id; // MongoDB ObjectID as string
final String mediaId;
final String mediaType;
final String mediaType; // "movie" or "tv"
final String title;
final String posterPath;
final DateTime? createdAt;
Favorite({
required this.id,
@@ -13,24 +14,29 @@ class Favorite {
required this.mediaType,
required this.title,
required this.posterPath,
this.createdAt,
});
factory Favorite.fromJson(Map<String, dynamic> json) {
return Favorite(
id: json['id'] as int? ?? 0,
id: json['id'] as String? ?? '',
mediaId: json['mediaId'] as String? ?? '',
mediaType: json['mediaType'] as String? ?? '',
mediaType: json['mediaType'] as String? ?? 'movie',
title: json['title'] as String? ?? '',
posterPath: json['posterPath'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String)
: null,
);
}
String get fullPosterUrl {
final baseUrl = dotenv.env['API_URL']!;
if (posterPath.isEmpty) {
return '$baseUrl/images/w500/placeholder.jpg';
return 'https://via.placeholder.com/500x750.png?text=No+Poster';
}
// TMDB CDN base URL
const tmdbBaseUrl = 'https://image.tmdb.org/t/p';
final cleanPath = posterPath.startsWith('/') ? posterPath.substring(1) : posterPath;
return '$baseUrl/images/w500/$cleanPath';
return '$tmdbBaseUrl/w500/$cleanPath';
}
}

View File

@@ -16,6 +16,8 @@ class Movie extends HiveObject {
@HiveField(2)
final String? posterPath;
final String? backdropPath;
@HiveField(3)
final String? overview;
@@ -51,6 +53,7 @@ class Movie extends HiveObject {
required this.id,
required this.title,
this.posterPath,
this.backdropPath,
this.overview,
this.releaseDate,
this.genres,
@@ -68,6 +71,7 @@ class Movie extends HiveObject {
id: (json['id'] as num).toString(), // Ensure id is a string
title: (json['title'] ?? json['name'] ?? '') as String,
posterPath: json['poster_path'] as String?,
backdropPath: json['backdrop_path'] as String?,
overview: json['overview'] as String?,
releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty
? DateTime.tryParse(json['release_date'] as String)
@@ -92,13 +96,26 @@ class Movie extends HiveObject {
Map<String, dynamic> toJson() => _$MovieToJson(this);
String get fullPosterUrl {
final baseUrl = dotenv.env['API_URL']!;
if (posterPath == null || posterPath!.isEmpty) {
// Use the placeholder from our own backend
return '$baseUrl/images/w500/placeholder.jpg';
// Use API placeholder
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
return '$apiUrl/api/v1/images/w500/placeholder.jpg';
}
// Null check is already performed above, so we can use `!`
// Use NeoMovies API images endpoint instead of TMDB directly
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
final cleanPath = posterPath!.startsWith('/') ? posterPath!.substring(1) : posterPath!;
return '$baseUrl/images/w500/$cleanPath';
return '$apiUrl/api/v1/images/w500/$cleanPath';
}
String get fullBackdropUrl {
if (backdropPath == null || backdropPath!.isEmpty) {
// Use API placeholder
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
return '$apiUrl/api/v1/images/w780/placeholder.jpg';
}
// Use NeoMovies API images endpoint instead of TMDB directly
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
final cleanPath = backdropPath!.startsWith('/') ? backdropPath!.substring(1) : backdropPath!;
return '$apiUrl/api/v1/images/w780/$cleanPath';
}
}

View File

@@ -81,6 +81,7 @@ Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
id: json['id'] as String,
title: json['title'] as String,
posterPath: json['posterPath'] as String?,
backdropPath: json['backdropPath'] as String?,
overview: json['overview'] as String?,
releaseDate: json['releaseDate'] == null
? null
@@ -100,6 +101,7 @@ Map<String, dynamic> _$MovieToJson(Movie instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'posterPath': instance.posterPath,
'backdropPath': instance.backdropPath,
'overview': instance.overview,
'releaseDate': instance.releaseDate?.toIso8601String(),
'genres': instance.genres,

View File

@@ -0,0 +1,34 @@
class AudioTrack {
final String name;
final String language;
final String url;
final bool isDefault;
AudioTrack({
required this.name,
required this.language,
required this.url,
this.isDefault = false,
});
factory AudioTrack.fromJson(Map<String, dynamic> json) {
return AudioTrack(
name: json['name'] ?? '',
language: json['language'] ?? '',
url: json['url'] ?? '',
isDefault: json['isDefault'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'language': language,
'url': url,
'isDefault': isDefault,
};
}
@override
String toString() => name;
}

View File

@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'player_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlayerResponse _$PlayerResponseFromJson(Map<String, dynamic> json) =>
PlayerResponse(
embedUrl: json['embedUrl'] as String?,
playerType: json['playerType'] as String?,
error: json['error'] as String?,
);
Map<String, dynamic> _$PlayerResponseToJson(PlayerResponse instance) =>
<String, dynamic>{
'embedUrl': instance.embedUrl,
'playerType': instance.playerType,
'error': instance.error,
};

View File

@@ -0,0 +1,73 @@
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
import 'package:neomovies_mobile/data/models/player/subtitle.dart';
class PlayerSettings {
final VideoQuality? selectedQuality;
final AudioTrack? selectedAudioTrack;
final Subtitle? selectedSubtitle;
final double volume;
final double playbackSpeed;
final bool autoPlay;
final bool muted;
PlayerSettings({
this.selectedQuality,
this.selectedAudioTrack,
this.selectedSubtitle,
this.volume = 1.0,
this.playbackSpeed = 1.0,
this.autoPlay = true,
this.muted = false,
});
PlayerSettings copyWith({
VideoQuality? selectedQuality,
AudioTrack? selectedAudioTrack,
Subtitle? selectedSubtitle,
double? volume,
double? playbackSpeed,
bool? autoPlay,
bool? muted,
}) {
return PlayerSettings(
selectedQuality: selectedQuality ?? this.selectedQuality,
selectedAudioTrack: selectedAudioTrack ?? this.selectedAudioTrack,
selectedSubtitle: selectedSubtitle ?? this.selectedSubtitle,
volume: volume ?? this.volume,
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
autoPlay: autoPlay ?? this.autoPlay,
muted: muted ?? this.muted,
);
}
factory PlayerSettings.fromJson(Map<String, dynamic> json) {
return PlayerSettings(
selectedQuality: json['selectedQuality'] != null
? VideoQuality.fromJson(json['selectedQuality'])
: null,
selectedAudioTrack: json['selectedAudioTrack'] != null
? AudioTrack.fromJson(json['selectedAudioTrack'])
: null,
selectedSubtitle: json['selectedSubtitle'] != null
? Subtitle.fromJson(json['selectedSubtitle'])
: null,
volume: json['volume']?.toDouble() ?? 1.0,
playbackSpeed: json['playbackSpeed']?.toDouble() ?? 1.0,
autoPlay: json['autoPlay'] ?? true,
muted: json['muted'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'selectedQuality': selectedQuality?.toJson(),
'selectedAudioTrack': selectedAudioTrack?.toJson(),
'selectedSubtitle': selectedSubtitle?.toJson(),
'volume': volume,
'playbackSpeed': playbackSpeed,
'autoPlay': autoPlay,
'muted': muted,
};
}
}

View File

@@ -0,0 +1,34 @@
class Subtitle {
final String name;
final String language;
final String url;
final bool isDefault;
Subtitle({
required this.name,
required this.language,
required this.url,
this.isDefault = false,
});
factory Subtitle.fromJson(Map<String, dynamic> json) {
return Subtitle(
name: json['name'] ?? '',
language: json['language'] ?? '',
url: json['url'] ?? '',
isDefault: json['isDefault'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'language': language,
'url': url,
'isDefault': isDefault,
};
}
@override
String toString() => name;
}

View File

@@ -0,0 +1,38 @@
class VideoQuality {
final String quality;
final String url;
final int bandwidth;
final int width;
final int height;
VideoQuality({
required this.quality,
required this.url,
required this.bandwidth,
required this.width,
required this.height,
});
factory VideoQuality.fromJson(Map<String, dynamic> json) {
return VideoQuality(
quality: json['quality'] ?? '',
url: json['url'] ?? '',
bandwidth: json['bandwidth'] ?? 0,
width: json['width'] ?? 0,
height: json['height'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'quality': quality,
'url': url,
'bandwidth': bandwidth,
'width': width,
'height': height,
};
}
@override
String toString() => quality;
}

View File

@@ -14,12 +14,20 @@ class Reaction {
class UserReaction {
final String? reactionType;
final String? mediaType;
final String? mediaId;
UserReaction({this.reactionType});
UserReaction({
this.reactionType,
this.mediaType,
this.mediaId,
});
factory UserReaction.fromJson(Map<String, dynamic> json) {
return UserReaction(
reactionType: json['type'] as String?,
mediaType: json['mediaType'] as String?,
mediaId: json['mediaId'] as String?,
);
}
}

View File

@@ -27,12 +27,8 @@ mixin _$Torrent {
int? get seeders => throw _privateConstructorUsedError;
int? get size => throw _privateConstructorUsedError;
/// Serializes this Torrent to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
$TorrentCopyWith<Torrent> get copyWith => throw _privateConstructorUsedError;
}
@@ -60,8 +56,6 @@ class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -125,8 +119,6 @@ class __$$TorrentImplCopyWithImpl<$Res>
_$TorrentImpl _value, $Res Function(_$TorrentImpl) _then)
: super(_value, _then);
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -211,14 +203,12 @@ class _$TorrentImpl implements _Torrent {
(identical(other.size, size) || other.size == size));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
@override
int get hashCode =>
Object.hash(runtimeType, magnet, title, name, quality, seeders, size);
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
@@ -255,11 +245,8 @@ abstract class _Torrent implements Torrent {
int? get seeders;
@override
int? get size;
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,29 @@
import 'package:json_annotation/json_annotation.dart';
part 'torrent_item.g.dart';
@JsonSerializable()
class TorrentItem {
final String? title;
final String? magnetUrl;
final String? quality;
final int? seeders;
final int? leechers;
final String? size;
final String? source;
TorrentItem({
this.title,
this.magnetUrl,
this.quality,
this.seeders,
this.leechers,
this.size,
this.source,
});
factory TorrentItem.fromJson(Map<String, dynamic> json) =>
_$TorrentItemFromJson(json);
Map<String, dynamic> toJson() => _$TorrentItemToJson(this);
}

View File

@@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'torrent_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TorrentItem _$TorrentItemFromJson(Map<String, dynamic> json) => TorrentItem(
title: json['title'] as String?,
magnetUrl: json['magnetUrl'] as String?,
quality: json['quality'] as String?,
seeders: (json['seeders'] as num?)?.toInt(),
leechers: (json['leechers'] as num?)?.toInt(),
size: json['size'] as String?,
source: json['source'] as String?,
);
Map<String, dynamic> _$TorrentItemToJson(TorrentItem instance) =>
<String, dynamic>{
'title': instance.title,
'magnetUrl': instance.magnetUrl,
'quality': instance.quality,
'seeders': instance.seeders,
'leechers': instance.leechers,
'size': instance.size,
'source': instance.source,
};

View File

@@ -33,8 +33,13 @@ class AuthRepository {
}
Future<void> verifyEmail(String email, String code) async {
await _apiClient.verify(email, code);
// After successful verification, the user should log in.
final response = await _apiClient.verify(email, code);
// Auto-login user after successful verification
await _storageService.saveToken(response.token);
await _storageService.saveUserData(
name: response.user.name,
email: response.user.email,
);
}
Future<void> resendVerificationCode(String email) async {

View File

@@ -10,7 +10,7 @@ class ReactionsRepository {
return await _apiClient.getReactionCounts(mediaType, mediaId);
}
Future<UserReaction> getMyReaction(String mediaType,String mediaId) async {
Future<UserReaction?> getMyReaction(String mediaType,String mediaId) async {
return await _apiClient.getMyReaction(mediaType, mediaId);
}

View File

@@ -108,9 +108,6 @@ class _$TorrentStateCopyWithImpl<$Res, $Val extends TorrentState>
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
@@ -127,9 +124,6 @@ class __$$InitialImplCopyWithImpl<$Res>
__$$InitialImplCopyWithImpl(
_$InitialImpl _value, $Res Function(_$InitialImpl) _then)
: super(_value, _then);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
@@ -268,9 +262,6 @@ class __$$LoadingImplCopyWithImpl<$Res>
__$$LoadingImplCopyWithImpl(
_$LoadingImpl _value, $Res Function(_$LoadingImpl) _then)
: super(_value, _then);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
@@ -419,8 +410,6 @@ class __$$LoadedImplCopyWithImpl<$Res>
_$LoadedImpl _value, $Res Function(_$LoadedImpl) _then)
: super(_value, _then);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -551,9 +540,7 @@ class _$LoadedImpl implements _Loaded {
const DeepCollectionEquality().hash(_availableSeasons),
selectedQuality);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$LoadedImplCopyWith<_$LoadedImpl> get copyWith =>
@@ -678,10 +665,7 @@ abstract class _Loaded implements TorrentState {
int? get selectedSeason;
List<int>? get availableSeasons;
String? get selectedQuality;
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
_$$LoadedImplCopyWith<_$LoadedImpl> get copyWith =>
throw _privateConstructorUsedError;
}
@@ -703,8 +687,6 @@ class __$$ErrorImplCopyWithImpl<$Res>
_$ErrorImpl _value, $Res Function(_$ErrorImpl) _then)
: super(_value, _then);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -743,9 +725,7 @@ class _$ErrorImpl implements _Error {
@override
int get hashCode => Object.hash(runtimeType, message);
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$ErrorImplCopyWith<_$ErrorImpl> get copyWith =>
@@ -854,10 +834,7 @@ abstract class _Error implements TorrentState {
const factory _Error({required final String message}) = _$ErrorImpl;
String get message;
/// Create a copy of TorrentState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@JsonKey(ignore: true)
_$$ErrorImplCopyWith<_$ErrorImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -93,9 +93,9 @@ class AuthProvider extends ChangeNotifier {
notifyListeners();
try {
await _authRepository.verifyEmail(email, code);
// After verification, user should log in.
// For a better UX, we could auto-login them, but for now, we'll just go to the unauthenticated state.
_state = AuthState.unauthenticated;
// Auto-login after successful verification
_user = await _authRepository.getCurrentUser();
_state = AuthState.authenticated;
} catch (e) {
_error = e.toString();
_state = AuthState.error;

View File

@@ -33,21 +33,32 @@ class MovieDetailProvider with ChangeNotifier {
notifyListeners();
try {
// Load movie/TV details
if (mediaType == 'movie') {
_movie = await _movieRepository.getMovieById(mediaId.toString());
} else {
_movie = await _movieRepository.getTvById(mediaId.toString());
}
_isLoading = false;
notifyListeners();
// Try to load IMDb ID (non-blocking)
if (_movie != null) {
_imdbId = await _apiClient.getImdbId(mediaId, mediaType);
try {
_imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType);
} catch (e) {
// IMDb ID loading failed, but don't fail the whole screen
print('Failed to load IMDb ID: $e');
_imdbId = null;
}
}
} catch (e) {
print('Error loading media: $e');
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
} finally {
_isImdbLoading = false;
notifyListeners();
}

View File

@@ -48,7 +48,7 @@ class ReactionsProvider with ChangeNotifier {
if (_authProvider.isAuthenticated) {
final userReactionResult = await _repository.getMyReaction(mediaType, mediaId);
_userReaction = userReactionResult.reactionType;
_userReaction = userReactionResult?.reactionType;
} else {
_userReaction = null;
}

View File

@@ -61,16 +61,7 @@ class _VerifyScreenState extends State<VerifyScreen> {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<AuthProvider>(context, listen: false)
.verifyEmail(widget.email, _code)
.then((_) {
final auth = Provider.of<AuthProvider>(context, listen: false);
if (auth.state != AuthState.error) {
Navigator.of(context).pop(); // Go back to LoginScreen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email verified. You can now login.')),
);
}
});
.verifyEmail(widget.email, _code);
}
}
@@ -82,6 +73,16 @@ class _VerifyScreenState extends State<VerifyScreen> {
),
body: Consumer<AuthProvider>(
builder: (context, auth, child) {
// Auto-navigate when user becomes authenticated
if (auth.state == AuthState.authenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pop(); // Go back to previous screen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email verified and logged in successfully!')),
);
});
}
return Form(
key: _formKey,
child: Padding(

View File

@@ -12,6 +12,7 @@ import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
import webview_flutter_wkwebview
@@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View File

@@ -161,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
chewie:
dependency: "direct main"
description:
name: chewie
sha256: "19b93a1e60e4ba640a792208a6543f1c7d5b124d011ce0199e2f18802199d984"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
cli_util:
dependency: transitive
description:
@@ -209,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -456,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
@@ -1069,6 +1093,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a"
url: "https://pub.dev"
source: hosted
version: "2.10.0"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "6cfe0b1e102522eda1e139b82bf00602181c5844fd2885340f595fb213d74842"
url: "https://pub.dev"
source: hosted
version: "2.8.14"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd
url: "https://pub.dev"
source: hosted
version: "2.8.4"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a
url: "https://pub.dev"
source: hosted
version: "6.4.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
@@ -1191,4 +1255,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.29.0"

View File

@@ -52,6 +52,9 @@ dependencies:
# Video Player (WebView only)
webview_flutter: ^4.7.0
wakelock_plus: ^1.2.1
# Video Player with native controls
video_player: ^2.9.2
chewie: ^1.8.5
# Utils
equatable: ^2.0.5
url_launcher: ^6.3.2