added Anibrain recommendations

This commit is contained in:
Schnitzel5 2025-08-15 21:42:47 +02:00
parent 22087752f1
commit c7d1d75045
11 changed files with 927 additions and 18 deletions

View file

@ -430,6 +430,12 @@
"include_sensitive_settings": "Include sensitive settings (e.g., tracker login tokens)",
"create": "Create",
"downloads_are_limited_to_wifi": "Downloads are limited to Wi-Fi only",
"recommendations_similar": "similar",
"recommendations_weights": "Recommendation Weights",
"recommendations_weights_genre": "Genre Similarity",
"recommendations_weights_setting": "Setting Similarity",
"recommendations_weights_synopsis": "Story Similarity",
"recommendations_weights_theme": "Theme Similarity",
"manga_extensions_repo": "Manga extensions repo",
"anime_extensions_repo": "Anime extensions repo",
"novel_extensions_repo": "Novel extensions repo",

View file

@ -258,6 +258,8 @@ class Settings {
bool? rpcShowCoverImage;
late AlgorithmWeights? algorithmWeights;
Settings({
this.id = 227,
this.updatedAt = 0,
@ -373,6 +375,7 @@ class Settings {
this.rpcShowReadingWatchingProgress = true,
this.rpcShowTitle = true,
this.rpcShowCoverImage = true,
this.algorithmWeights,
});
Settings.fromJson(Map<String, dynamic> json) {
@ -594,6 +597,9 @@ class Settings {
rpcShowReadingWatchingProgress = json['rpcShowReadingWatchingProgress'];
rpcShowTitle = json['rpcShowTitle'];
rpcShowCoverImage = json['rpcShowCoverImage'];
algorithmWeights = json['algorithmWeights'] != null
? AlgorithmWeights.fromJson(json['algorithmWeights'])
: null;
}
Map<String, dynamic> toJson() => {
@ -732,6 +738,8 @@ class Settings {
'rpcShowReadingWatchingProgress': rpcShowReadingWatchingProgress,
'rpcShowTitle': rpcShowTitle,
'rpcShowCoverImage': rpcShowCoverImage,
if (algorithmWeights != null)
'algorithmWeights': algorithmWeights!.toJson(),
};
}
@ -1088,6 +1096,35 @@ class PlayerSubtitleSettings {
};
}
@embedded
class AlgorithmWeights {
int? genre;
int? setting;
int? synopsis;
int? theme;
AlgorithmWeights({
this.genre = 30,
this.setting = 15,
this.synopsis = 40,
this.theme = 20,
});
AlgorithmWeights.fromJson(Map<String, dynamic> json) {
genre = json['genre'];
setting = json['setting'];
synopsis = json['synopsis'];
theme = json['theme'];
}
Map<String, dynamic> toJson() => {
'genre': genre,
'setting': setting,
'synopsis': synopsis,
'theme': theme,
};
}
enum ColorFilterBlendMode {
none,
multiply,

View file

@ -124,12 +124,14 @@ class _BrowseScreenState extends ConsumerState<BrowseScreen>
} else {
context.push(
'/globalSearch',
extra:
switch (_tabList[_tabBarController.index]) {
"manga" => ItemType.manga,
"anime" => ItemType.anime,
_ => ItemType.novel,
},
extra: (
null,
switch (_tabList[_tabBarController.index]) {
"manga" => ItemType.manga,
"anime" => ItemType.anime,
_ => ItemType.novel,
},
),
);
}
},

View file

@ -24,15 +24,16 @@ import 'package:mangayomi/modules/widgets/manga_image_card_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class GlobalSearchScreen extends ConsumerStatefulWidget {
final String? search;
final ItemType itemType;
const GlobalSearchScreen({required this.itemType, super.key});
const GlobalSearchScreen({this.search, required this.itemType, super.key});
@override
ConsumerState<GlobalSearchScreen> createState() => _GlobalSearchScreenState();
}
class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
String query = "";
String _query = "";
final _textEditingController = TextEditingController();
late final List<Source> sourceList =
ref.read(onlyIncludePinnedSourceStateProvider)
@ -50,8 +51,17 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
.and()
.itemTypeEqualTo(widget.itemType)
.findAllSync();
@override
void initState() {
super.initState();
_textEditingController.text = widget.search ?? "";
}
@override
Widget build(BuildContext context) {
final query = _query.isNotEmpty ? _query : widget.search ?? "";
return Scaffold(
appBar: AppBar(
leading: Container(),
@ -62,27 +72,27 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
Navigator.pop(context);
},
onFieldSubmitted: (value) async {
if (!(query == _textEditingController.text)) {
if (!(_query == _textEditingController.text)) {
setState(() {
query = "";
_query = "";
});
await Future.delayed(const Duration(milliseconds: 10));
setState(() {
query = value;
_query = value;
});
}
},
onSuffixPressed: () {
_textEditingController.clear();
setState(() {
query = "";
_query = "";
});
},
controller: _textEditingController,
),
],
),
body: query.isNotEmpty
body: _query.isNotEmpty || widget.search != null
? SuperListView.builder(
itemCount: sourceList.length,
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
@ -90,7 +100,11 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
final source = sourceList[index];
return SizedBox(
height: 260,
child: SourceSearchScreen(query: query, source: source),
child: SourceSearchScreen(
key: ValueKey(query),
query: query,
source: source,
),
);
},
)

View file

@ -22,6 +22,7 @@ import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.d
import 'package:mangayomi/modules/manga/detail/widgets/tracker_search_widget.dart';
import 'package:mangayomi/modules/manga/detail/widgets/tracker_widget.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart';
import 'package:mangayomi/modules/more/settings/track/widgets/track_listile.dart';
import 'package:mangayomi/modules/widgets/category_selection_dialog.dart';
@ -1694,6 +1695,37 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
),
),
),
const SizedBox(height: 15),
SizedBox(
width: context.width(1),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: OutlinedButton.icon(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30.0),
),
),
),
onPressed: () {
final algorithmWeights = ref.read(
algorithmWeightsStateProvider,
);
context.push(
"/recommendations",
extra: (
widget.manga!.name,
widget.manga!.itemType,
algorithmWeights,
),
);
},
label: Text(l10n.recommendations),
icon: Icon(Icons.arrow_right_alt_outlined),
),
),
),
if (!context.isTablet)
Column(
children: [

View file

@ -0,0 +1,383 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/services/recommendation.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:marquee/marquee.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class RecommendationScreen extends StatefulWidget {
final String name;
final ItemType itemType;
final AlgorithmWeights algorithmWeights;
const RecommendationScreen({
super.key,
required this.name,
required this.itemType,
required this.algorithmWeights,
});
@override
State<RecommendationScreen> createState() => _RecommendationScreenState();
}
class _RecommendationScreenState extends State<RecommendationScreen> {
String _errorMessage = "";
bool _isLoading = true;
List<RecommendationResult>? data;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_errorMessage = "";
data = await getRecommendations(
widget.name,
widget.itemType,
widget.algorithmWeights,
);
if (mounted) {
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.recommendations)),
body: Padding(
padding: EdgeInsetsGeometry.all(5),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Builder(
builder: (context) {
if (_errorMessage.isNotEmpty) {
return Center(child: Text(_errorMessage));
}
if (data != null && data!.isNotEmpty) {
return SuperListView.builder(
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
itemCount: data!.length,
itemBuilder: (context, index) {
final recommendation = data![index];
return ListTile(
onTap: () => context.push(
'/globalSearch',
extra: (
recommendation.titleEnglish ??
recommendation.titleRomaji ??
recommendation.titleNative,
widget.itemType,
),
),
title: Row(
children: [
if (recommendation.imgURLs.isNotEmpty)
_thumbnailPreview(
context,
recommendation.imgURLs.first,
),
const SizedBox(width: 15),
recommendation.description != null
? Flexible(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_buildTitle(
recommendation.titleEnglish ??
recommendation.titleRomaji ??
recommendation.titleNative ??
"",
context,
),
Text(
recommendation.description!,
style: const TextStyle(
fontSize: 11,
),
overflow: TextOverflow.clip,
),
],
),
)
: Flexible(
child: _buildTitle(
recommendation.titleEnglish ??
recommendation.titleRomaji ??
recommendation.titleNative ??
"",
context,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: recommendation.genres.isEmpty
? const SizedBox(height: 15)
: context.isTablet
? Wrap(
children: [
for (
var i = 0;
i < recommendation.genres.length;
i++
)
Padding(
padding: const EdgeInsets.only(
left: 2,
right: 2,
bottom: 5,
),
child: SizedBox(
height: 30,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: Colors
.grey
.withValues(
alpha: 0.2,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(
5,
),
),
),
onPressed: null,
child: Text(
recommendation.genres[i],
style: TextStyle(
fontSize: 11.5,
color: context.isLight
? Colors.black
: Colors.white,
),
),
),
),
),
],
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment:
MainAxisAlignment.start,
children: [
for (
var i = 0;
i <
recommendation
.genres
.length;
i++
)
Padding(
padding:
const EdgeInsets.only(
left: 2,
right: 2,
bottom: 5,
),
child: SizedBox(
height: 30,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: Colors
.grey
.withValues(
alpha: 0.2,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(
5,
),
),
),
onPressed: () {},
child: Text(
recommendation
.genres[i],
style: TextStyle(
fontSize: 11.5,
color: context.isLight
? Colors.black
: Colors.white,
),
),
),
),
),
],
),
),
),
const SizedBox(width: 15),
Text(
"${recommendation.score}% ${l10n.recommendations_similar}",
style: TextStyle(
background: Paint()
..color = Theme.of(context)
.scaffoldBackgroundColor
.withValues(alpha: 0.75)
..strokeWidth = 30.0
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
);
}
return Center(child: Text(l10n.no_result));
},
),
),
);
}
Widget _buildTitle(String text, BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Make sure that (constraints.maxWidth - (35 + 5)) is strictly positive.
final double availableWidth = constraints.maxWidth - (35 + 5);
final textPainter =
TextPainter(
text: TextSpan(text: text, style: const TextStyle(fontSize: 13)),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(
maxWidth: availableWidth > 0 ? availableWidth : 1.0,
); // - Download icon size (download_page_widget.dart, Widget Build SizedBox width: 35)
final isOverflowing = textPainter.didExceedMaxLines;
if (isOverflowing) {
return SizedBox(
height: 20,
child: Marquee(
text: text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
blankSpace: 40.0,
velocity: 30.0,
pauseAfterRound: const Duration(seconds: 1),
startPadding: 10.0,
),
);
} else {
return Text(
text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
);
}
},
);
}
Widget _thumbnailPreview(BuildContext context, String? imageUrl) {
final imageProvider = CustomExtendedNetworkImageProvider(
toImgUrl(imageUrl ?? ""),
);
return Padding(
padding: const EdgeInsets.all(3),
child: GestureDetector(
onTap: () {
_openImage(context, imageProvider);
},
child: SizedBox(
width: 100,
height: 150,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(5)),
image: DecorationImage(image: imageProvider, fit: BoxFit.cover),
),
),
),
),
);
}
void _openImage(BuildContext context, ImageProvider imageProvider) {
showDialog(
context: context,
builder: (context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
itemCount: 1,
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider,
minScale: PhotoViewComputedScale.contained,
maxScale: 2.0,
);
},
loadingBuilder: (context, event) {
return const ProgressCenter();
},
),
),
],
),
);
},
);
}
}
class SuperPrecalculationPolicy extends ExtentPrecalculationPolicy {
@override
bool shouldPrecalculateExtents(ExtentPrecalculationContext context) {
return context.numberOfItems < 100;
}
}

View file

@ -0,0 +1,35 @@
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'algorithm_weights_state_provider.g.dart';
@riverpod
class AlgorithmWeightsState extends _$AlgorithmWeightsState {
@override
AlgorithmWeights build() {
return isar.settings.getSync(227)!.algorithmWeights ?? AlgorithmWeights();
}
void set(AlgorithmWeights value) {
final settings = isar.settings.getSync(227)!;
state = value;
isar.writeTxnSync(
() => isar.settings.putSync(
settings
..algorithmWeights = state
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
}
void setWeights({int? genre, int? setting, int? synopsis, int? theme}) {
set(
AlgorithmWeights(
genre: genre ?? state.genre,
setting: setting ?? state.setting,
synopsis: synopsis ?? state.synopsis,
theme: theme ?? state.theme,
),
);
}
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'algorithm_weights_state_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$algorithmWeightsStateHash() =>
r'5c20cb9b195a73161b485e082ad024b138c3da9c';
/// See also [AlgorithmWeightsState].
@ProviderFor(AlgorithmWeightsState)
final algorithmWeightsStateProvider = AutoDisposeNotifierProvider<
AlgorithmWeightsState, AlgorithmWeights>.internal(
AlgorithmWeightsState.new,
name: r'algorithmWeightsStateProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$algorithmWeightsStateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AlgorithmWeightsState = AutoDisposeNotifier<AlgorithmWeights>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,14 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
import 'package:mangayomi/modules/more/settings/general/providers/general_state_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
class GeneralScreen extends ConsumerWidget {
class GeneralScreen extends ConsumerStatefulWidget {
const GeneralScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<GeneralScreen> createState() => _GeneralStateScreen();
}
class _GeneralStateScreen extends ConsumerState<GeneralScreen> {
int _genre = 0;
int _setting = 0;
int _synopsis = 0;
int _theme = 0;
@override
void initState() {
super.initState();
final algorithmWeights = ref.read(algorithmWeightsStateProvider);
_genre = algorithmWeights.genre!;
_setting = algorithmWeights.setting!;
_synopsis = algorithmWeights.synopsis!;
_theme = algorithmWeights.theme!;
}
@override
Widget build(BuildContext context) {
final l10n = l10nLocalizations(context);
final enableDiscordRpc = ref.watch(enableDiscordRpcStateProvider);
final hideDiscordRpcInIncognito = ref.watch(
@ -24,6 +48,199 @@ class GeneralScreen extends ConsumerWidget {
body: SingleChildScrollView(
child: Column(
children: [
Container(
margin: const EdgeInsets.all(20.0),
padding: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
border: Border.all(width: 3.0, color: context.primaryColor),
borderRadius: BorderRadius.all(Radius.circular(5.0)),
),
child: Column(
children: [
Row(
children: [
Text(
context.l10n.recommendations_weights,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 20),
OutlinedButton.icon(
onPressed: () {
final defaultWeights = AlgorithmWeights();
setState(() {
_genre = defaultWeights.genre!;
_setting = defaultWeights.setting!;
_synopsis = defaultWeights.synopsis!;
_theme = defaultWeights.theme!;
});
ref
.read(algorithmWeightsStateProvider.notifier)
.set(defaultWeights);
},
label: Text(context.l10n.reset),
icon: const Icon(Icons.restore),
),
],
),
const SizedBox(height: 10),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.recommendations_weights_genre),
Text(
(_genre / 100).toStringAsFixed(2),
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
const SizedBox(height: 20),
SliderTheme(
data: SliderTheme.of(context).copyWith(
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 5.0,
),
),
child: Slider.adaptive(
min: 0,
max: 100,
value: _genre.toDouble(),
onChanged: (value) {
HapticFeedback.vibrate();
setState(() {
_genre = value.toInt();
});
},
onChangeEnd: (value) => ref
.read(algorithmWeightsStateProvider.notifier)
.setWeights(genre: _genre),
),
),
],
),
),
const SizedBox(height: 10),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.recommendations_weights_setting),
Text(
(_setting / 100).toStringAsFixed(2),
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
const SizedBox(height: 20),
SliderTheme(
data: SliderTheme.of(context).copyWith(
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 5.0,
),
),
child: Slider.adaptive(
min: 0,
max: 100,
value: _setting.toDouble(),
onChanged: (value) {
HapticFeedback.vibrate();
setState(() {
_setting = value.toInt();
});
},
onChangeEnd: (value) => ref
.read(algorithmWeightsStateProvider.notifier)
.setWeights(setting: _setting),
),
),
],
),
),
const SizedBox(height: 10),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.recommendations_weights_synopsis),
Text(
(_synopsis / 100).toStringAsFixed(2),
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
const SizedBox(height: 20),
SliderTheme(
data: SliderTheme.of(context).copyWith(
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 5.0,
),
),
child: Slider.adaptive(
min: 0,
max: 100,
value: _synopsis.toDouble(),
onChanged: (value) {
HapticFeedback.vibrate();
setState(() {
_synopsis = value.toInt();
});
},
onChangeEnd: (value) => ref
.read(algorithmWeightsStateProvider.notifier)
.setWeights(synopsis: _synopsis),
),
),
],
),
),
const SizedBox(height: 10),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.recommendations_weights_theme),
Text(
(_theme / 100).toStringAsFixed(2),
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
const SizedBox(height: 20),
SliderTheme(
data: SliderTheme.of(context).copyWith(
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 5.0,
),
),
child: Slider.adaptive(
min: 0,
max: 100,
value: _theme.toDouble(),
onChanged: (value) {
HapticFeedback.vibrate();
setState(() {
_theme = value.toInt();
});
},
onChangeEnd: (value) => ref
.read(algorithmWeightsStateProvider.notifier)
.setWeights(theme: _theme),
),
),
],
),
),
],
),
),
SwitchListTile(
value: enableDiscordRpc,
title: Text(l10n.enable_discord_rpc),

View file

@ -3,6 +3,7 @@ import 'package:bot_toast/bot_toast.dart';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/models/track_search.dart';
@ -12,6 +13,7 @@ import 'package:mangayomi/modules/browse/extension/extension_detail.dart';
import 'package:mangayomi/modules/browse/extension/widgets/create_extension.dart';
import 'package:mangayomi/modules/browse/sources/sources_filter_screen.dart';
import 'package:mangayomi/modules/manga/detail/widgets/migrate_screen.dart';
import 'package:mangayomi/modules/manga/detail/widgets/recommendation_screen.dart';
import 'package:mangayomi/modules/more/data_and_storage/create_backup.dart';
import 'package:mangayomi/modules/more/data_and_storage/data_and_storage.dart';
import 'package:mangayomi/modules/more/settings/appearance/custom_navigation_settings.dart';
@ -168,9 +170,9 @@ class RouterNotifier extends ChangeNotifier {
name: "extension_detail",
builder: (source) => ExtensionDetail(source: source),
),
_genericRoute<ItemType>(
_genericRoute<(String?, ItemType)>(
name: "globalSearch",
builder: (itemType) => GlobalSearchScreen(itemType: itemType),
builder: (data) => GlobalSearchScreen(search: data.$1, itemType: data.$2),
),
_genericRoute(name: "about", child: const AboutScreen()),
_genericRoute(name: "track", child: const TrackScreen()),
@ -222,6 +224,14 @@ class RouterNotifier extends ChangeNotifier {
name: "migrate/tracker",
builder: (data) => MigrationScreen(manga: data.$1, trackSearch: data.$2),
),
_genericRoute<(String, ItemType, AlgorithmWeights)>(
name: "recommendations",
builder: (data) => RecommendationScreen(
name: data.$1,
itemType: data.$2,
algorithmWeights: data.$3,
),
),
];
GoRoute _genericRoute<T>({

View file

@ -0,0 +1,146 @@
import 'dart:convert';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/services/http/m_client.dart';
Future<List<RecommendationResult>?> getRecommendations(
String name,
ItemType itemType,
AlgorithmWeights algorithmWeights,
) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final mediaId = await _getSuggest(http, name, itemType);
return _getRecommendation(
http,
mediaId ?? name,
itemType,
algorithmWeights,
);
} catch (_) {
return null;
}
}
Future<List<RecommendationResult>?> _getRecommendation(
InterceptedClient http,
String mediaId,
ItemType itemType,
AlgorithmWeights algorithmWeights,
) async {
final url =
"https://anibrain.ai/api/-/recommender/recs/${itemType != ItemType.anime ? "manga" : "anime"}";
final res = await http.get(
Uri.parse(url),
headers: {
"priority": "u=1, i",
"Referer": "https://anibrain.ai/",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
},
params: {
"filterCountry": '[]',
"filterFormat": '${_fillerType(itemType).map((e) => '"$e"').toList()}',
"filterGenre": '{}',
"filterTag": '{"max":{},"min":{}}',
"filterRelease": '[1930,${DateTime.now().year}]',
"filterScore": 0,
"algorithmWeights": _algorithmWeights(algorithmWeights),
"mediaId": mediaId,
"mediaType": _mediaType(itemType),
"adult": false,
"page": 1,
},
);
final data = json.decode(res.body) as Map<String, dynamic>;
return (data["data"] as List?)
?.map((e) => RecommendationResult.fromJson(e))
.toList();
}
Future<String?> _getSuggest(
InterceptedClient http,
String name,
ItemType itemType,
) async {
final url =
"https://anibrain.ai/api/-/recommender/autosuggest?searchValue=$name&mediaType=${_mediaType(itemType)}&adult=false";
final res = await http.get(
Uri.parse(url),
headers: {
"priority": "u=1, i",
"Referer": "https://anibrain.ai/recommender/manga",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
},
);
final data = json.decode(res.body) as Map<String, dynamic>;
final list = (data["data"] as List?)?.map((e) => e["id"]);
return list?.firstOrNull;
}
String _algorithmWeights(AlgorithmWeights algorithmWeights) {
final genre = ((algorithmWeights.genre ?? 30) / 100).toStringAsFixed(2);
final setting = ((algorithmWeights.setting ?? 15) / 100).toStringAsFixed(2);
final synopsis = ((algorithmWeights.synopsis ?? 40) / 100).toStringAsFixed(2);
final theme = ((algorithmWeights.theme ?? 20) / 100).toStringAsFixed(2);
return '{"genre":$genre,"setting":$setting,"synopsis":$synopsis,"theme":$theme}';
}
String _mediaType(ItemType itemType) {
return switch (itemType) {
ItemType.manga => "MANGA",
ItemType.anime => "ANIME",
ItemType.novel => "NOVEL",
};
}
List<String> _fillerType(ItemType itemType) {
return switch (itemType) {
ItemType.manga => ["MANGA"],
ItemType.anime => ["movie", "ona", "tv"],
ItemType.novel => ["NOVEL"],
};
}
class RecommendationResult {
final String id;
final int? anilistId;
final int? myanimelistId;
final int score;
final String? titleRomaji;
final String? titleEnglish;
final String? titleNative;
final String? description;
final List<String> imgURLs;
final List<String> genres;
RecommendationResult({
required this.id,
this.anilistId,
this.myanimelistId,
required this.score,
this.titleRomaji,
this.titleEnglish,
this.titleNative,
this.description,
required this.imgURLs,
required this.genres,
});
factory RecommendationResult.fromJson(Map<String, dynamic> json) {
return RecommendationResult(
id: json["id"],
anilistId: json["anilistId"],
myanimelistId: json["myanimelistId"],
score: json["score"],
titleRomaji: json["titleRomaji"],
titleEnglish: json["titleEnglish"],
titleNative: json["titleNative"],
description: json["description"],
imgURLs: json["imgURLs"]?.cast<String>() ?? [],
genres: json["genres"]?.cast<String>() ?? [],
);
}
}