mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-27 19:58:50 +05:00
1. Add Downloads screen to main navigation:
- Import DownloadsScreen in main_screen.dart
- Replace placeholder 'Downloads Page' with actual DownloadsScreen component
- Downloads tab now fully functional with torrent management
2. Fix authentication models for API compatibility:
- AuthResponse: Handle wrapped API response with 'data' field
- User model: Add 'verified' field, support both '_id' and 'id' fields
- Add toJson() method to User for serialization
- Fix parsing to match backend response format
3. Fix movie details screen (gray screen issue):
- Implement getExternalIds() in NeoMoviesApiClient
- Add IMDb ID fetching via /movies/{id}/external-ids endpoint
- Update api_client.dart to use new getExternalIds method
- Fix movie detail provider to properly load IMDb IDs
Changes:
- lib/presentation/screens/main_screen.dart: Add DownloadsScreen import and replace placeholder
- lib/data/models/auth_response.dart: Handle wrapped 'data' response
- lib/data/models/user.dart: Add verified field and toJson method
- lib/data/api/neomovies_api_client.dart: Add getExternalIds endpoint
- lib/data/api/api_client.dart: Implement getImdbId using external IDs
Result:
✅ Downloads tab works and shows torrent list
✅ Authentication properly parses API responses
✅ Movie detail screen loads IMDb IDs correctly
✅ All API models match backend format
531 lines
17 KiB
Dart
531 lines
17 KiB
Dart
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/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)
|
|
/// This client provides improved performance and new features:
|
|
/// - Email verification flow
|
|
/// - Google OAuth support
|
|
/// - Torrent search via RedAPI
|
|
/// - Multiple player support (Alloha, Lumex, Vibix)
|
|
/// - Enhanced reactions system
|
|
class NeoMoviesApiClient {
|
|
final http.Client _client;
|
|
final String _baseUrl;
|
|
final String _apiVersion = 'v1';
|
|
|
|
NeoMoviesApiClient(this._client, {String? baseUrl})
|
|
: _baseUrl = baseUrl ?? dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
|
|
|
String get apiUrl => '$_baseUrl/api/$_apiVersion';
|
|
|
|
// ============================================
|
|
// Authentication Endpoints
|
|
// ============================================
|
|
|
|
/// Register a new user (sends verification code to email)
|
|
/// Returns: {"success": true, "message": "Verification code sent"}
|
|
Future<Map<String, dynamic>> register({
|
|
required String email,
|
|
required String password,
|
|
required String name,
|
|
}) async {
|
|
final uri = Uri.parse('$apiUrl/auth/register');
|
|
final response = await _client.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({
|
|
'email': email,
|
|
'password': password,
|
|
'name': name,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
|
return json.decode(response.body);
|
|
} else {
|
|
throw Exception('Registration failed: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Verify email with code sent during registration
|
|
/// Returns: AuthResponse with JWT token and user info
|
|
Future<AuthResponse> verifyEmail({
|
|
required String email,
|
|
required String code,
|
|
}) async {
|
|
final uri = Uri.parse('$apiUrl/auth/verify');
|
|
final response = await _client.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({
|
|
'email': email,
|
|
'code': code,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
return AuthResponse.fromJson(json.decode(response.body));
|
|
} else {
|
|
throw Exception('Verification failed: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Resend verification code to email
|
|
Future<void> resendVerificationCode(String email) async {
|
|
final uri = Uri.parse('$apiUrl/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}');
|
|
}
|
|
}
|
|
|
|
/// Login with email and password
|
|
Future<AuthResponse> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
final uri = Uri.parse('$apiUrl/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('Login failed: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Get Google OAuth login URL
|
|
/// User should be redirected to this URL in a WebView
|
|
String getGoogleOAuthUrl() {
|
|
return '$apiUrl/auth/google/login';
|
|
}
|
|
|
|
/// Refresh authentication token
|
|
Future<AuthResponse> refreshToken(String refreshToken) async {
|
|
final uri = Uri.parse('$apiUrl/auth/refresh');
|
|
final response = await _client.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({'refreshToken': refreshToken}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
return AuthResponse.fromJson(json.decode(response.body));
|
|
} else {
|
|
throw Exception('Token refresh failed: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Get current user profile
|
|
Future<User> getProfile() async {
|
|
final uri = Uri.parse('$apiUrl/auth/profile');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
return User.fromJson(json.decode(response.body));
|
|
} else {
|
|
throw Exception('Failed to get profile: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Delete user account
|
|
Future<void> deleteAccount() async {
|
|
final uri = Uri.parse('$apiUrl/auth/profile');
|
|
final response = await _client.delete(uri);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception('Failed to delete account: ${response.body}');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Movies Endpoints
|
|
// ============================================
|
|
|
|
/// Get popular movies
|
|
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
|
return _fetchMovies('/movies/popular', page: page);
|
|
}
|
|
|
|
/// Get top rated movies
|
|
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
|
return _fetchMovies('/movies/top-rated', page: page);
|
|
}
|
|
|
|
/// Get upcoming movies
|
|
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
|
return _fetchMovies('/movies/upcoming', page: page);
|
|
}
|
|
|
|
/// Get now playing movies
|
|
Future<List<Movie>> getNowPlayingMovies({int page = 1}) async {
|
|
return _fetchMovies('/movies/now-playing', page: page);
|
|
}
|
|
|
|
/// Get movie by ID
|
|
Future<Movie> getMovieById(String id) async {
|
|
final uri = Uri.parse('$apiUrl/movies/$id');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
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}');
|
|
}
|
|
}
|
|
|
|
/// Get movie recommendations
|
|
Future<List<Movie>> getMovieRecommendations(String movieId, {int page = 1}) async {
|
|
return _fetchMovies('/movies/$movieId/recommendations', page: page);
|
|
}
|
|
|
|
/// Search movies
|
|
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
|
|
return _fetchMovies('/movies/search', page: page, query: query);
|
|
}
|
|
|
|
// ============================================
|
|
// TV Shows Endpoints
|
|
// ============================================
|
|
|
|
/// Get popular TV shows
|
|
Future<List<Movie>> getPopularTvShows({int page = 1}) async {
|
|
return _fetchMovies('/tv/popular', page: page);
|
|
}
|
|
|
|
/// Get top rated TV shows
|
|
Future<List<Movie>> getTopRatedTvShows({int page = 1}) async {
|
|
return _fetchMovies('/tv/top-rated', page: page);
|
|
}
|
|
|
|
/// Get TV show by ID
|
|
Future<Movie> getTvShowById(String id) async {
|
|
final uri = Uri.parse('$apiUrl/tv/$id');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
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}');
|
|
}
|
|
}
|
|
|
|
/// Get TV show recommendations
|
|
Future<List<Movie>> getTvShowRecommendations(String tvId, {int page = 1}) async {
|
|
return _fetchMovies('/tv/$tvId/recommendations', page: page);
|
|
}
|
|
|
|
/// Search TV shows
|
|
Future<List<Movie>> searchTvShows(String query, {int page = 1}) async {
|
|
return _fetchMovies('/tv/search', page: page, query: query);
|
|
}
|
|
|
|
// ============================================
|
|
// External IDs (IMDb, TVDB, etc.)
|
|
// ============================================
|
|
|
|
/// Get external IDs (IMDb, TVDB) for a movie or TV show
|
|
Future<String?> getExternalIds(String mediaId, String mediaType) async {
|
|
try {
|
|
final uri = Uri.parse('$apiUrl/${mediaType}s/$mediaId/external-ids');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
final apiResponse = json.decode(response.body);
|
|
final data = (apiResponse is Map && apiResponse['data'] != null)
|
|
? apiResponse['data']
|
|
: apiResponse;
|
|
return data['imdb_id'] as String?;
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
print('Error getting external IDs: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Unified Search
|
|
// ============================================
|
|
|
|
/// Search both movies and TV shows
|
|
Future<List<Movie>> search(String query, {int page = 1}) async {
|
|
final results = await Future.wait([
|
|
searchMovies(query, page: page),
|
|
searchTvShows(query, page: page),
|
|
]);
|
|
|
|
// Combine and return
|
|
return [...results[0], ...results[1]];
|
|
}
|
|
|
|
// ============================================
|
|
// Favorites Endpoints
|
|
// ============================================
|
|
|
|
/// Get user's favorite movies/shows
|
|
Future<List<Favorite>> getFavorites() async {
|
|
final uri = Uri.parse('$apiUrl/favorites');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
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}');
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// 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}');
|
|
}
|
|
}
|
|
|
|
/// Remove movie/show from favorites
|
|
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) {
|
|
throw Exception('Failed to remove favorite: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// 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!)
|
|
// ============================================
|
|
|
|
/// Get reaction counts for a movie/show
|
|
Future<Map<String, int>> getReactionCounts({
|
|
required String mediaType,
|
|
required String mediaId,
|
|
}) async {
|
|
final uri = Uri.parse('$apiUrl/reactions/$mediaType/$mediaId/counts');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body);
|
|
return Map<String, int>.from(data);
|
|
} else {
|
|
throw Exception('Failed to get reactions: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Add or update user's reaction
|
|
Future<void> setReaction({
|
|
required String mediaType,
|
|
required String mediaId,
|
|
required String reactionType, // 'like' or 'dislike'
|
|
}) async {
|
|
final uri = Uri.parse('$apiUrl/reactions/$mediaType/$mediaId');
|
|
final response = await _client.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({'type': reactionType}),
|
|
);
|
|
|
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
|
throw Exception('Failed to set reaction: ${response.body}');
|
|
}
|
|
}
|
|
|
|
/// Get user's own reactions
|
|
Future<List<UserReaction>> getMyReactions() async {
|
|
final uri = Uri.parse('$apiUrl/reactions/my');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
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}');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Torrent Search Endpoints (NEW!)
|
|
// ============================================
|
|
|
|
/// Search torrents for a movie/show via RedAPI
|
|
/// @param imdbId - IMDb ID (e.g., "tt1234567")
|
|
/// @param type - "movie" or "series"
|
|
/// @param quality - "1080p", "720p", "480p", etc.
|
|
Future<List<TorrentItem>> searchTorrents({
|
|
required String imdbId,
|
|
required String type,
|
|
String? quality,
|
|
String? season,
|
|
String? episode,
|
|
}) async {
|
|
final queryParams = {
|
|
'type': type,
|
|
if (quality != null) 'quality': quality,
|
|
if (season != null) 'season': season,
|
|
if (episode != null) 'episode': episode,
|
|
};
|
|
|
|
final uri = Uri.parse('$apiUrl/torrents/search/$imdbId')
|
|
.replace(queryParameters: queryParams);
|
|
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
final List<dynamic> data = json.decode(response.body);
|
|
return data.map((json) => TorrentItem.fromJson(json)).toList();
|
|
} else {
|
|
throw Exception('Failed to search torrents: ${response.body}');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Players Endpoints (NEW!)
|
|
// ============================================
|
|
|
|
/// Get Alloha player embed URL
|
|
Future<PlayerResponse> getAllohaPlayer(String imdbId) async {
|
|
return _getPlayer('/players/alloha/$imdbId');
|
|
}
|
|
|
|
/// Get Lumex player embed URL
|
|
Future<PlayerResponse> getLumexPlayer(String imdbId) async {
|
|
return _getPlayer('/players/lumex/$imdbId');
|
|
}
|
|
|
|
/// Get Vibix player embed URL
|
|
Future<PlayerResponse> getVibixPlayer(String imdbId) async {
|
|
return _getPlayer('/players/vibix/$imdbId');
|
|
}
|
|
|
|
// ============================================
|
|
// Private Helper Methods
|
|
// ============================================
|
|
|
|
/// Generic method to fetch movies/TV shows
|
|
Future<List<Movie>> _fetchMovies(
|
|
String endpoint, {
|
|
int page = 1,
|
|
String? query,
|
|
}) async {
|
|
final queryParams = {
|
|
'page': page.toString(),
|
|
if (query != null && query.isNotEmpty) 'query': query,
|
|
};
|
|
|
|
final uri = Uri.parse('$apiUrl$endpoint').replace(queryParameters: queryParams);
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
final decoded = json.decode(response.body);
|
|
|
|
// API returns: {"success": true, "data": {"page": 1, "results": [...], ...}}
|
|
List<dynamic> results;
|
|
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'];
|
|
} else {
|
|
throw Exception('Unexpected response format');
|
|
}
|
|
|
|
return results.map((json) => Movie.fromJson(json)).toList();
|
|
} else {
|
|
throw Exception('Failed to load from $endpoint: ${response.statusCode}');
|
|
}
|
|
}
|
|
|
|
/// Generic method to fetch player info
|
|
Future<PlayerResponse> _getPlayer(String endpoint) async {
|
|
final uri = Uri.parse('$apiUrl$endpoint');
|
|
final response = await _client.get(uri);
|
|
|
|
if (response.statusCode == 200) {
|
|
return PlayerResponse.fromJson(json.decode(response.body));
|
|
} else {
|
|
throw Exception('Failed to get player: ${response.body}');
|
|
}
|
|
}
|
|
}
|