add torrent api(magnet links)

This commit is contained in:
2025-07-19 18:13:13 +03:00
parent 05311129f3
commit 4ea75db105
18 changed files with 2329 additions and 15 deletions

View File

@@ -1,9 +1,11 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'movie.g.dart';
@HiveType(typeId: 0)
@JsonSerializable()
class Movie extends HiveObject {
@HiveField(0)
final String id;
@@ -87,6 +89,8 @@ class Movie extends HiveObject {
);
}
Map<String, dynamic> toJson() => _$MovieToJson(this);
String get fullPosterUrl {
final baseUrl = dotenv.env['API_URL']!;
if (posterPath == null || posterPath!.isEmpty) {

View File

@@ -24,13 +24,18 @@ class MovieAdapter extends TypeAdapter<Movie> {
releaseDate: fields[4] as DateTime?,
genres: (fields[5] as List?)?.cast<String>(),
voteAverage: fields[6] as double?,
popularity: fields[9] as double,
runtime: fields[7] as int?,
seasonsCount: fields[10] as int?,
episodesCount: fields[11] as int?,
tagline: fields[8] as String?,
);
}
@override
void write(BinaryWriter writer, Movie obj) {
writer
..writeByte(7)
..writeByte(12)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -44,7 +49,17 @@ class MovieAdapter extends TypeAdapter<Movie> {
..writeByte(5)
..write(obj.genres)
..writeByte(6)
..write(obj.voteAverage);
..write(obj.voteAverage)
..writeByte(9)
..write(obj.popularity)
..writeByte(7)
..write(obj.runtime)
..writeByte(10)
..write(obj.seasonsCount)
..writeByte(11)
..write(obj.episodesCount)
..writeByte(8)
..write(obj.tagline);
}
@override
@@ -57,3 +72,42 @@ class MovieAdapter extends TypeAdapter<Movie> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
id: json['id'] as String,
title: json['title'] as String,
posterPath: json['posterPath'] as String?,
overview: json['overview'] as String?,
releaseDate: json['releaseDate'] == null
? null
: DateTime.parse(json['releaseDate'] as String),
genres:
(json['genres'] as List<dynamic>?)?.map((e) => e as String).toList(),
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
runtime: (json['runtime'] as num?)?.toInt(),
seasonsCount: (json['seasonsCount'] as num?)?.toInt(),
episodesCount: (json['episodesCount'] as num?)?.toInt(),
tagline: json['tagline'] as String?,
mediaType: json['mediaType'] as String? ?? 'movie',
);
Map<String, dynamic> _$MovieToJson(Movie instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'posterPath': instance.posterPath,
'overview': instance.overview,
'releaseDate': instance.releaseDate?.toIso8601String(),
'genres': instance.genres,
'voteAverage': instance.voteAverage,
'popularity': instance.popularity,
'runtime': instance.runtime,
'seasonsCount': instance.seasonsCount,
'episodesCount': instance.episodesCount,
'tagline': instance.tagline,
'mediaType': instance.mediaType,
};

View File

@@ -1,8 +1,10 @@
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'movie_preview.g.dart';
@HiveType(typeId: 1) // Use a new typeId to avoid conflicts with Movie
@JsonSerializable()
class MoviePreview extends HiveObject {
@HiveField(0)
final String id;
@@ -18,4 +20,7 @@ class MoviePreview extends HiveObject {
required this.title,
this.posterPath,
});
factory MoviePreview.fromJson(Map<String, dynamic> json) => _$MoviePreviewFromJson(json);
Map<String, dynamic> toJson() => _$MoviePreviewToJson(this);
}

View File

@@ -45,3 +45,20 @@ class MoviePreviewAdapter extends TypeAdapter<MoviePreview> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MoviePreview _$MoviePreviewFromJson(Map<String, dynamic> json) => MoviePreview(
id: json['id'] as String,
title: json['title'] as String,
posterPath: json['posterPath'] as String?,
);
Map<String, dynamic> _$MoviePreviewToJson(MoviePreview instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'posterPath': instance.posterPath,
};

View File

@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'torrent.freezed.dart';
part 'torrent.g.dart';
@freezed
class Torrent with _$Torrent {
const factory Torrent({
required String magnet,
String? title,
String? name,
String? quality,
int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb,
}) = _Torrent;
factory Torrent.fromJson(Map<String, dynamic> json) => _$TorrentFromJson(json);
}

View File

@@ -0,0 +1,268 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'torrent.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
Torrent _$TorrentFromJson(Map<String, dynamic> json) {
return _Torrent.fromJson(json);
}
/// @nodoc
mixin _$Torrent {
String get magnet => throw _privateConstructorUsedError;
String? get title => throw _privateConstructorUsedError;
String? get name => throw _privateConstructorUsedError;
String? get quality => throw _privateConstructorUsedError;
int? get seeders => throw _privateConstructorUsedError;
@JsonKey(name: 'size_gb')
double? get sizeGb => 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)
$TorrentCopyWith<Torrent> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TorrentCopyWith<$Res> {
factory $TorrentCopyWith(Torrent value, $Res Function(Torrent) then) =
_$TorrentCopyWithImpl<$Res, Torrent>;
@useResult
$Res call(
{String magnet,
String? title,
String? name,
String? quality,
int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb});
}
/// @nodoc
class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
implements $TorrentCopyWith<$Res> {
_$TorrentCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// 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({
Object? magnet = null,
Object? title = freezed,
Object? name = freezed,
Object? quality = freezed,
Object? seeders = freezed,
Object? sizeGb = freezed,
}) {
return _then(_value.copyWith(
magnet: null == magnet
? _value.magnet
: magnet // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
quality: freezed == quality
? _value.quality
: quality // ignore: cast_nullable_to_non_nullable
as String?,
seeders: freezed == seeders
? _value.seeders
: seeders // ignore: cast_nullable_to_non_nullable
as int?,
sizeGb: freezed == sizeGb
? _value.sizeGb
: sizeGb // ignore: cast_nullable_to_non_nullable
as double?,
) as $Val);
}
}
/// @nodoc
abstract class _$$TorrentImplCopyWith<$Res> implements $TorrentCopyWith<$Res> {
factory _$$TorrentImplCopyWith(
_$TorrentImpl value, $Res Function(_$TorrentImpl) then) =
__$$TorrentImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String magnet,
String? title,
String? name,
String? quality,
int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb});
}
/// @nodoc
class __$$TorrentImplCopyWithImpl<$Res>
extends _$TorrentCopyWithImpl<$Res, _$TorrentImpl>
implements _$$TorrentImplCopyWith<$Res> {
__$$TorrentImplCopyWithImpl(
_$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({
Object? magnet = null,
Object? title = freezed,
Object? name = freezed,
Object? quality = freezed,
Object? seeders = freezed,
Object? sizeGb = freezed,
}) {
return _then(_$TorrentImpl(
magnet: null == magnet
? _value.magnet
: magnet // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
quality: freezed == quality
? _value.quality
: quality // ignore: cast_nullable_to_non_nullable
as String?,
seeders: freezed == seeders
? _value.seeders
: seeders // ignore: cast_nullable_to_non_nullable
as int?,
sizeGb: freezed == sizeGb
? _value.sizeGb
: sizeGb // ignore: cast_nullable_to_non_nullable
as double?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TorrentImpl implements _Torrent {
const _$TorrentImpl(
{required this.magnet,
this.title,
this.name,
this.quality,
this.seeders,
@JsonKey(name: 'size_gb') this.sizeGb});
factory _$TorrentImpl.fromJson(Map<String, dynamic> json) =>
_$$TorrentImplFromJson(json);
@override
final String magnet;
@override
final String? title;
@override
final String? name;
@override
final String? quality;
@override
final int? seeders;
@override
@JsonKey(name: 'size_gb')
final double? sizeGb;
@override
String toString() {
return 'Torrent(magnet: $magnet, title: $title, name: $name, quality: $quality, seeders: $seeders, sizeGb: $sizeGb)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TorrentImpl &&
(identical(other.magnet, magnet) || other.magnet == magnet) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.quality, quality) || other.quality == quality) &&
(identical(other.seeders, seeders) || other.seeders == seeders) &&
(identical(other.sizeGb, sizeGb) || other.sizeGb == sizeGb));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, magnet, title, name, quality, seeders, sizeGb);
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
__$$TorrentImplCopyWithImpl<_$TorrentImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TorrentImplToJson(
this,
);
}
}
abstract class _Torrent implements Torrent {
const factory _Torrent(
{required final String magnet,
final String? title,
final String? name,
final String? quality,
final int? seeders,
@JsonKey(name: 'size_gb') final double? sizeGb}) = _$TorrentImpl;
factory _Torrent.fromJson(Map<String, dynamic> json) = _$TorrentImpl.fromJson;
@override
String get magnet;
@override
String? get title;
@override
String? get name;
@override
String? get quality;
@override
int? get seeders;
@override
@JsonKey(name: 'size_gb')
double? get sizeGb;
/// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'torrent.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$TorrentImpl _$$TorrentImplFromJson(Map<String, dynamic> json) =>
_$TorrentImpl(
magnet: json['magnet'] as String,
title: json['title'] as String?,
name: json['name'] as String?,
quality: json['quality'] as String?,
seeders: (json['seeders'] as num?)?.toInt(),
sizeGb: (json['size_gb'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) =>
<String, dynamic>{
'magnet': instance.magnet,
'title': instance.title,
'name': instance.name,
'quality': instance.quality,
'seeders': instance.seeders,
'size_gb': instance.sizeGb,
};

View File

@@ -0,0 +1,144 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/torrent.dart';
class TorrentService {
static const String _baseUrl = 'API_URL';
String get apiUrl => dotenv.env[_baseUrl] ?? 'http://localhost:3000';
/// Получить торренты по IMDB ID
/// [imdbId] - IMDB ID фильма/сериала (например, 'tt1234567')
/// [type] - тип контента: 'movie' или 'tv'
/// [season] - номер сезона для сериалов (опционально)
Future<List<Torrent>> getTorrents({
required String imdbId,
required String type,
int? season,
}) async {
try {
final uri = Uri.parse('$apiUrl/torrents/search/$imdbId').replace(
queryParameters: {
'type': type,
if (season != null) 'season': season.toString(),
},
);
final response = await http.get(
uri,
headers: {
'Content-Type': 'application/json',
},
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['results'] as List<dynamic>? ?? [];
return results
.map((json) => Torrent.fromJson(json as Map<String, dynamic>))
.toList();
} else if (response.statusCode == 404) {
// Торренты не найдены - возвращаем пустой список
return [];
} else {
throw Exception('HTTP ${response.statusCode}: ${response.body}');
}
} catch (e) {
throw Exception('Ошибка загрузки торрентов: $e');
}
}
/// Определить качество из названия торрента
String? detectQuality(String title) {
final titleLower = title.toLowerCase();
// Порядок важен - сначала более специфичные паттерны
if (titleLower.contains('2160p') || titleLower.contains('4k')) {
return '4K';
}
if (titleLower.contains('1440p') || titleLower.contains('2k')) {
return '1440p';
}
if (titleLower.contains('1080p')) {
return '1080p';
}
if (titleLower.contains('720p')) {
return '720p';
}
if (titleLower.contains('480p')) {
return '480p';
}
if (titleLower.contains('360p')) {
return '360p';
}
return null;
}
/// Группировать торренты по качеству
Map<String, List<Torrent>> groupTorrentsByQuality(List<Torrent> torrents) {
final groups = <String, List<Torrent>>{};
for (final torrent in torrents) {
final title = torrent.title ?? torrent.name ?? '';
final quality = detectQuality(title) ?? 'Неизвестно';
if (!groups.containsKey(quality)) {
groups[quality] = [];
}
groups[quality]!.add(torrent);
}
// Сортируем торренты внутри каждой группы по количеству сидов (убывание)
for (final group in groups.values) {
group.sort((a, b) => (b.seeders ?? 0).compareTo(a.seeders ?? 0));
}
// Возвращаем группы в порядке качества (от высокого к низкому)
final sortedGroups = <String, List<Torrent>>{};
const qualityOrder = ['4K', '1440p', '1080p', '720p', '480p', '360p', 'Неизвестно'];
for (final quality in qualityOrder) {
if (groups.containsKey(quality) && groups[quality]!.isNotEmpty) {
sortedGroups[quality] = groups[quality]!;
}
}
return sortedGroups;
}
/// Получить доступные сезоны для сериала
/// [imdbId] - IMDB ID сериала
Future<List<int>> getAvailableSeasons(String imdbId) async {
try {
// Получаем все торренты для сериала без указания сезона
final torrents = await getTorrents(imdbId: imdbId, type: 'tv');
// Извлекаем номера сезонов из названий торрентов
final seasons = <int>{};
for (final torrent in torrents) {
final title = torrent.title ?? torrent.name ?? '';
final seasonRegex = RegExp(r'(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон', caseSensitive: false);
final matches = seasonRegex.allMatches(title);
for (final match in matches) {
final seasonStr = match.group(1) ?? match.group(2);
if (seasonStr != null) {
final seasonNumber = int.tryParse(seasonStr);
if (seasonNumber != null && seasonNumber > 0) {
seasons.add(seasonNumber);
}
}
}
}
final sortedSeasons = seasons.toList()..sort();
return sortedSeasons;
} catch (e) {
throw Exception('Ошибка получения списка сезонов: $e');
}
}
}