feat(all): Implement MAL import for anime and manga lists
This commit is contained in:
205
lib/src/ui/bloc/settings_bloc.dart
Normal file
205
lib/src/ui/bloc/settings_bloc.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:anitrack/src/data/anime.dart';
|
||||
import 'package:anitrack/src/data/manga.dart';
|
||||
import 'package:anitrack/src/data/type.dart';
|
||||
import 'package:anitrack/src/service/database.dart';
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:jikan_api/jikan_api.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
part 'settings_state.dart';
|
||||
part 'settings_event.dart';
|
||||
part 'settings_bloc.freezed.dart';
|
||||
|
||||
MediumTrackingState malStatusToTrackingState(String status) {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return MediumTrackingState.completed;
|
||||
case 'Reading':
|
||||
case 'Watching':
|
||||
return MediumTrackingState.ongoing;
|
||||
case 'Plan to Read':
|
||||
case 'Plan to Watch':
|
||||
return MediumTrackingState.planned;
|
||||
case 'Dropped':
|
||||
return MediumTrackingState.dropped;
|
||||
case 'On-Hold':
|
||||
return MediumTrackingState.paused;
|
||||
default:
|
||||
assert(false, 'Invalid status $status');
|
||||
return MediumTrackingState.planned;
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
|
||||
SettingsBloc() : super(SettingsState()) {
|
||||
on<AnimeListImportedEvent>(_onAnimeListImported);
|
||||
on<MangaListImportedEvent>(_onMangaListImported);
|
||||
}
|
||||
|
||||
void _showLoadingSpinner(Emitter<SettingsState> emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
importSpinnerVisible: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _hideLoadingSpinner(Emitter<SettingsState> emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
importSpinnerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAnimeListImported(
|
||||
AnimeListImportedEvent event,
|
||||
Emitter<SettingsState> emit,
|
||||
) async {
|
||||
assert(
|
||||
event.type == ImportListType.mal,
|
||||
'Only MAL imports are currently supported',
|
||||
);
|
||||
|
||||
_showLoadingSpinner(emit);
|
||||
final inputStream = InputFileStream(event.path);
|
||||
final listRaw = GZipDecoder().decodeBuffer(inputStream);
|
||||
final listXml = utf8.decode(listRaw);
|
||||
final document = XmlDocument.parse(listXml);
|
||||
final mal = document.getElement('myanimelist');
|
||||
if (mal == null) {
|
||||
print('Invalid MAL list export');
|
||||
_hideLoadingSpinner(emit);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
importCurrent: 0,
|
||||
importTotal: mal.childElements.length - 1,
|
||||
),
|
||||
);
|
||||
|
||||
for (final anime in mal.childElements) {
|
||||
if (anime.qualifiedName == 'myinfo') {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
importCurrent: state.importCurrent + 1,
|
||||
),
|
||||
);
|
||||
|
||||
final title = anime.getElement('series_title')!.text;
|
||||
final totalEpisodes =
|
||||
int.parse(anime.getElement('series_episodes')!.text);
|
||||
final id = anime.getElement('series_animedb_id')!.text;
|
||||
|
||||
print('Waiting 500ms to not hammer Jikan ($title)');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Query the MAL api
|
||||
final data = await Jikan().getAnime(int.parse(id));
|
||||
|
||||
// Add the anime
|
||||
await GetIt.I.get<DatabaseService>().addAnime(
|
||||
AnimeTrackingData(
|
||||
id,
|
||||
malStatusToTrackingState(
|
||||
anime.getElement('my_status')!.text,
|
||||
),
|
||||
title,
|
||||
int.parse(anime.getElement('my_watched_episodes')!.text),
|
||||
// 0 means that MAL does not know
|
||||
totalEpisodes == 0 ? null : totalEpisodes,
|
||||
data.imageUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Hide the spinner again
|
||||
_hideLoadingSpinner(emit);
|
||||
}
|
||||
|
||||
Future<void> _onMangaListImported(
|
||||
MangaListImportedEvent event,
|
||||
Emitter<SettingsState> emit,
|
||||
) async {
|
||||
assert(
|
||||
event.type == ImportListType.mal,
|
||||
'Only MAL imports are currently supported',
|
||||
);
|
||||
|
||||
_showLoadingSpinner(emit);
|
||||
final inputStream = InputFileStream(event.path);
|
||||
final listRaw = GZipDecoder().decodeBuffer(inputStream);
|
||||
final listXml = utf8.decode(listRaw);
|
||||
final document = XmlDocument.parse(listXml);
|
||||
final mal = document.getElement('myanimelist');
|
||||
if (mal == null) {
|
||||
print('Invalid MAL list export');
|
||||
_hideLoadingSpinner(emit);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
importCurrent: 0,
|
||||
importTotal: mal.childElements.length - 1,
|
||||
),
|
||||
);
|
||||
|
||||
for (final manga in mal.childElements) {
|
||||
if (manga.qualifiedName == 'myinfo') {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
importCurrent: state.importCurrent + 1,
|
||||
),
|
||||
);
|
||||
|
||||
final title = manga.getElement('manga_title')!.text;
|
||||
final totalChapters = int.parse(manga.getElement('manga_chapters')!.text);
|
||||
final id = manga.getElement('manga_mangadb_id')!.text;
|
||||
|
||||
print('Waiting 500ms to not hammer Jikan ($title)');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Query the MAL api
|
||||
Manga data;
|
||||
try {
|
||||
data = await Jikan().getManga(int.parse(id));
|
||||
} catch (_) {
|
||||
print('API request failed');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the manga
|
||||
await GetIt.I.get<DatabaseService>().addManga(
|
||||
MangaTrackingData(
|
||||
id,
|
||||
malStatusToTrackingState(
|
||||
manga.getElement('my_status')!.text,
|
||||
),
|
||||
title,
|
||||
int.parse(manga.getElement('my_read_chapters')!.text),
|
||||
0,
|
||||
// 0 means that MAL does not know
|
||||
totalChapters == 0 ? null : totalChapters,
|
||||
data.imageUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Hide the spinner again
|
||||
_hideLoadingSpinner(emit);
|
||||
}
|
||||
}
|
||||
177
lib/src/ui/bloc/settings_bloc.freezed.dart
Normal file
177
lib/src/ui/bloc/settings_bloc.freezed.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
// 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
|
||||
|
||||
part of 'settings_bloc.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#custom-getters-and-methods');
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SettingsState {
|
||||
bool get importSpinnerVisible => throw _privateConstructorUsedError;
|
||||
int get importCurrent => throw _privateConstructorUsedError;
|
||||
int get importTotal => throw _privateConstructorUsedError;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
$SettingsStateCopyWith<SettingsState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SettingsStateCopyWith<$Res> {
|
||||
factory $SettingsStateCopyWith(
|
||||
SettingsState value, $Res Function(SettingsState) then) =
|
||||
_$SettingsStateCopyWithImpl<$Res>;
|
||||
$Res call({bool importSpinnerVisible, int importCurrent, int importTotal});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SettingsStateCopyWithImpl<$Res>
|
||||
implements $SettingsStateCopyWith<$Res> {
|
||||
_$SettingsStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
final SettingsState _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function(SettingsState) _then;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? importSpinnerVisible = freezed,
|
||||
Object? importCurrent = freezed,
|
||||
Object? importTotal = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
importSpinnerVisible: importSpinnerVisible == freezed
|
||||
? _value.importSpinnerVisible
|
||||
: importSpinnerVisible // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
importCurrent: importCurrent == freezed
|
||||
? _value.importCurrent
|
||||
: importCurrent // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
importTotal: importTotal == freezed
|
||||
? _value.importTotal
|
||||
: importTotal // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$_SettingsStateCopyWith<$Res>
|
||||
implements $SettingsStateCopyWith<$Res> {
|
||||
factory _$$_SettingsStateCopyWith(
|
||||
_$_SettingsState value, $Res Function(_$_SettingsState) then) =
|
||||
__$$_SettingsStateCopyWithImpl<$Res>;
|
||||
@override
|
||||
$Res call({bool importSpinnerVisible, int importCurrent, int importTotal});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$_SettingsStateCopyWithImpl<$Res>
|
||||
extends _$SettingsStateCopyWithImpl<$Res>
|
||||
implements _$$_SettingsStateCopyWith<$Res> {
|
||||
__$$_SettingsStateCopyWithImpl(
|
||||
_$_SettingsState _value, $Res Function(_$_SettingsState) _then)
|
||||
: super(_value, (v) => _then(v as _$_SettingsState));
|
||||
|
||||
@override
|
||||
_$_SettingsState get _value => super._value as _$_SettingsState;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? importSpinnerVisible = freezed,
|
||||
Object? importCurrent = freezed,
|
||||
Object? importTotal = freezed,
|
||||
}) {
|
||||
return _then(_$_SettingsState(
|
||||
importSpinnerVisible: importSpinnerVisible == freezed
|
||||
? _value.importSpinnerVisible
|
||||
: importSpinnerVisible // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
importCurrent: importCurrent == freezed
|
||||
? _value.importCurrent
|
||||
: importCurrent // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
importTotal: importTotal == freezed
|
||||
? _value.importTotal
|
||||
: importTotal // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$_SettingsState implements _SettingsState {
|
||||
_$_SettingsState(
|
||||
{this.importSpinnerVisible = false,
|
||||
this.importCurrent = 0,
|
||||
this.importTotal = 0});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool importSpinnerVisible;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int importCurrent;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int importTotal;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SettingsState(importSpinnerVisible: $importSpinnerVisible, importCurrent: $importCurrent, importTotal: $importTotal)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$_SettingsState &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.importSpinnerVisible, importSpinnerVisible) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.importCurrent, importCurrent) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.importTotal, importTotal));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(importSpinnerVisible),
|
||||
const DeepCollectionEquality().hash(importCurrent),
|
||||
const DeepCollectionEquality().hash(importTotal));
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
_$$_SettingsStateCopyWith<_$_SettingsState> get copyWith =>
|
||||
__$$_SettingsStateCopyWithImpl<_$_SettingsState>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _SettingsState implements SettingsState {
|
||||
factory _SettingsState(
|
||||
{final bool importSpinnerVisible,
|
||||
final int importCurrent,
|
||||
final int importTotal}) = _$_SettingsState;
|
||||
|
||||
@override
|
||||
bool get importSpinnerVisible;
|
||||
@override
|
||||
int get importCurrent;
|
||||
@override
|
||||
int get importTotal;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$_SettingsStateCopyWith<_$_SettingsState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
36
lib/src/ui/bloc/settings_event.dart
Normal file
36
lib/src/ui/bloc/settings_event.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
part of 'settings_bloc.dart';
|
||||
|
||||
enum ImportListType {
|
||||
// MyAnimeList
|
||||
mal,
|
||||
}
|
||||
|
||||
abstract class SettingsEvent {}
|
||||
|
||||
/// Triggered when an anime list is imported
|
||||
class AnimeListImportedEvent extends SettingsEvent {
|
||||
AnimeListImportedEvent(
|
||||
this.path,
|
||||
this.type,
|
||||
);
|
||||
|
||||
/// The path to the list we're importing
|
||||
final String path;
|
||||
|
||||
/// The type of list we're importing
|
||||
final ImportListType type;
|
||||
}
|
||||
|
||||
/// Triggered when a manga list is imported
|
||||
class MangaListImportedEvent extends SettingsEvent {
|
||||
MangaListImportedEvent(
|
||||
this.path,
|
||||
this.type,
|
||||
);
|
||||
|
||||
/// The path to the list we're importing
|
||||
final String path;
|
||||
|
||||
/// The type of list we're importing
|
||||
final ImportListType type;
|
||||
}
|
||||
10
lib/src/ui/bloc/settings_state.dart
Normal file
10
lib/src/ui/bloc/settings_state.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
part of 'settings_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class SettingsState with _$SettingsState {
|
||||
factory SettingsState({
|
||||
@Default(false) bool importSpinnerVisible,
|
||||
@Default(0) int importCurrent,
|
||||
@Default(0) int importTotal,
|
||||
}) = _SettingsState;
|
||||
}
|
||||
Reference in New Issue
Block a user