This commit is contained in:
Schnitzel5 2025-09-03 23:44:06 +02:00
parent 5200c2900d
commit 60928374a3
18 changed files with 684 additions and 217 deletions

View file

@ -286,6 +286,8 @@ class Settings {
late AlgorithmWeights? algorithmWeights;
List<String>? localFolders;
Settings({
this.id = 227,
this.updatedAt = 0,
@ -414,6 +416,7 @@ class Settings {
this.volumeBoostCap,
this.downloadedOnlyMode = false,
this.algorithmWeights,
this.localFolders,
});
Settings.fromJson(Map<String, dynamic> json) {
@ -652,6 +655,7 @@ class Settings {
algorithmWeights = json['algorithmWeights'] != null
? AlgorithmWeights.fromJson(json['algorithmWeights'])
: null;
localFolders = json['localFolders'];
}
Map<String, dynamic> toJson() => {
@ -804,6 +808,7 @@ class Settings {
'downloadedOnlyMode': downloadedOnlyMode,
if (algorithmWeights != null)
'algorithmWeights': algorithmWeights!.toJson(),
'localFolders': localFolders,
};
}

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ part of 'extensions_provider.dart';
// **************************************************************************
String _$getExtensionsStreamHash() =>
r'af34092ebf31c784010110af746e3ee2731297bd';
r'18790d3d4a7f52e5e7239c8726dcd09bb51d803a';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -178,6 +178,24 @@ class _SourcesScreenState extends ConsumerState<SourcesScreen> {
item1.name!.compareTo(item2.name!),
order: GroupedListOrder.ASC,
),
Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
children: [
Text(
l10n.other,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
SourceListTile(
source: Source(name: "local", lang: "", itemType: widget.itemType),
itemType: widget.itemType,
),
],
),
);

View file

@ -13,6 +13,9 @@ import 'package:mangayomi/utils/language.dart';
class SourceListTile extends StatelessWidget {
final ItemType itemType;
final Source source;
bool get isLocal => source.name == "local" && source.lang == "";
const SourceListTile({
super.key,
required this.source,
@ -24,21 +27,23 @@ class SourceListTile extends StatelessWidget {
return Consumer(
builder: (context, ref, child) => ListTile(
onTap: () {
final sources = isar.sources
.filter()
.idIsNotNull()
.and()
.itemTypeEqualTo(itemType)
.findAllSync();
isar.writeTxnSync(() {
for (var src in sources) {
isar.sources.putSync(
src
..lastUsed = src.id == source.id ? true : false
..updatedAt = DateTime.now().millisecondsSinceEpoch,
);
}
});
if (!isLocal) {
final sources = isar.sources
.filter()
.idIsNotNull()
.and()
.itemTypeEqualTo(itemType)
.findAllSync();
isar.writeTxnSync(() {
for (var src in sources) {
isar.sources.putSync(
src
..lastUsed = src.id == source.id ? true : false
..updatedAt = DateTime.now().millisecondsSinceEpoch,
);
}
});
}
context.push('/mangaHome', extra: (source, false));
},
leading: Container(
@ -73,7 +78,15 @@ class SourceListTile extends StatelessWidget {
),
],
),
title: Text(source.name!),
title: Text(
!isLocal
? source.name!
: "${context.l10n.local_source} ${source.itemType == ItemType.manga
? context.l10n.manga
: source.itemType == ItemType.anime
? context.l10n.anime
: context.l10n.novel}",
),
trailing: SizedBox(
width: 150,
child: Row(
@ -96,22 +109,23 @@ class SourceListTile extends StatelessWidget {
},
),
const SizedBox(width: 10),
IconButton(
padding: const EdgeInsets.all(0),
onPressed: () {
isar.writeTxnSync(
() => isar.sources.putSync(
source
..isPinned = !source.isPinned!
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
},
icon: Icon(
Icons.push_pin_outlined,
color: source.isPinned! ? context.primaryColor : null,
if (!isLocal)
IconButton(
padding: const EdgeInsets.all(0),
onPressed: () {
isar.writeTxnSync(
() => isar.sources.putSync(
source
..isPinned = !source.isPinned!
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
},
icon: Icon(
Icons.push_pin_outlined,
color: source.isPinned! ? context.primaryColor : null,
),
),
),
],
),
),

View file

@ -1,6 +1,7 @@
import 'dart:io'; // For I/O-operations
import 'package:isar/isar.dart'; // Isar database package for local storage
import 'package:mangayomi/main.dart'; // Exposes the global `isar` instance
import 'package:mangayomi/models/settings.dart';
import 'package:path/path.dart' as p; // For manipulating file system paths
import 'package:bot_toast/bot_toast.dart'; // For Exceptions
import 'package:mangayomi/models/manga.dart'; // Has Manga model and ItemType enum
@ -10,6 +11,26 @@ import 'package:mangayomi/providers/storage_provider.dart'; // Provides storage
import 'package:riverpod_annotation/riverpod_annotation.dart'; // Annotations for code generation
part 'file_scanner.g.dart';
@riverpod
class LocalFoldersState extends _$LocalFoldersState {
@override
List<String> build() {
return isar.settings.getSync(227)!.localFolders ?? [];
}
void setDownloadedOnly(List<String> value) {
final settings = isar.settings.getSync(227)!;
state = value;
isar.writeTxnSync(
() => isar.settings.putSync(
settings
..localFolders = state
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
}
}
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
/// **Folder structure:**
@ -18,25 +39,38 @@ part 'file_scanner.g.dart';
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// Mangayomi/local/NovelName/Chapter1.epub
/// Mangayomi/local/NovelName/Chapter2.html
/// ```
/// **Supported filetypes:** (taken from lib/modules/library/providers/local_archive.dart, line 98)
/// ```
/// Videotypes: mp4, mov, avi, flv, wmv, mpeg, mkv
/// Imagetypes: jpg, jpeg, png, webp
/// Archivetypes: cbz, zip, cbt, tar
/// Other types: epub, html
/// ```
@riverpod
Future<void> scanLocalLibrary(Ref ref) async {
// Get /local directory
final localDir = await _getLocalLibrary();
await _scanDirectory(ref, localDir);
final customDirs = ref.read(localFoldersStateProvider);
for (final dir in customDirs) {
await _scanDirectory(ref, Directory(dir));
}
}
Future<void> _scanDirectory(Ref ref, Directory? dir) async {
// Don't do anything if /local doesn't exist
if (localDir == null || !await localDir.exists()) return;
if (dir == null || !await dir.exists()) return;
final dateNow = DateTime.now().millisecondsSinceEpoch;
// Fetch all existing mangas in library that are in /local (or \local)
final List<Manga> existingMangas = await isar.mangas
.filter()
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
@ -84,7 +118,7 @@ Future<void> scanLocalLibrary(Ref ref) async {
}
// Iterate over each sub-directory (each representing a title, Manga or Anime)
await for (final folder in localDir.list()) {
await for (final folder in dir.list()) {
if (folder is! Directory) continue;
final title = p.basename(folder.path); // Anime/Manga title
String relativePath = _getRelativePath(folder.path);
@ -98,11 +132,14 @@ Future<void> scanLocalLibrary(Ref ref) async {
final hasImagesFolders = subDirs.isNotEmpty;
final hasArchives = files.any((f) => _isArchive(f.path));
final hasVideos = files.any((f) => _isVideo(f.path));
final hasEpubs = files.any((f) => _isEpubHtml(f.path));
late ItemType itemType;
if (hasImagesFolders || hasArchives) {
itemType = ItemType.manga;
} else if (hasVideos) {
itemType = ItemType.anime;
} else if (hasEpubs) {
itemType = ItemType.novel;
} else {
continue; // nothing to import from this folder
}
@ -115,7 +152,7 @@ Future<void> scanLocalLibrary(Ref ref) async {
manga = mangaMap[relativePath]!;
} else {
manga = Manga(
favorite: true,
favorite: false,
source: 'local',
author: '',
artist: '',
@ -168,6 +205,11 @@ Future<void> scanLocalLibrary(Ref ref) async {
final videos = files.where((f) => _isVideo(f.path)).toList();
addNewChapters(videos, false);
}
if (hasEpubs) {
// Each .mp4 is an episode
final epubs = files.where((f) => _isEpubHtml(f.path)).toList();
addNewChapters(epubs, false);
}
}
final changedMangas = <Manga>[];
@ -335,3 +377,9 @@ bool _isVideo(String path) {
};
return videoExtensions.contains(ext);
}
/// Returns if file is an epub or html
bool _isEpubHtml(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.epub' || ext == '.html';
}

View file

@ -6,7 +6,7 @@ part of 'file_scanner.dart';
// RiverpodGenerator
// **************************************************************************
String _$scanLocalLibraryHash() => r'efbad9aa5fa4233e260a2e132389c23b40ef515a';
String _$scanLocalLibraryHash() => r'80267fbc1da18bbd7ef6c8c4ef87bcea9ad99869';
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
@ -16,12 +16,15 @@ String _$scanLocalLibraryHash() => r'efbad9aa5fa4233e260a2e132389c23b40ef515a';
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// Mangayomi/local/NovelName/Chapter1.epub
/// Mangayomi/local/NovelName/Chapter2.html
/// ```
/// **Supported filetypes:** (taken from lib/modules/library/providers/local_archive.dart, line 98)
/// ```
/// Videotypes: mp4, mov, avi, flv, wmv, mpeg, mkv
/// Imagetypes: jpg, jpeg, png, webp
/// Archivetypes: cbz, zip, cbt, tar
/// Other types: epub, html
/// ```
///
/// Copied from [scanLocalLibrary].
@ -39,5 +42,21 @@ final scanLocalLibraryProvider = AutoDisposeFutureProvider<void>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ScanLocalLibraryRef = AutoDisposeFutureProviderRef<void>;
String _$localFoldersStateHash() => r'3bea18b0e5e6d9d1950e12293825fc85b1a0de6c';
/// See also [LocalFoldersState].
@ProviderFor(LocalFoldersState)
final localFoldersStateProvider =
AutoDisposeNotifierProvider<LocalFoldersState, List<String>>.internal(
LocalFoldersState.new,
name: r'localFoldersStateProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$localFoldersStateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LocalFoldersState = AutoDisposeNotifier<List<String>>;
// 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

@ -63,7 +63,8 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
? 2
: 0;
late Source source = widget.source;
late List<dynamic> filters = getFilterList(source: source);
late bool isLocal = source.name == "local" && source.lang == "";
late List<dynamic> filters = isLocal ? [] : getFilterList(source: source);
final List<MManga> _mangaList = [];
List<TypeMangaSelector> _types(BuildContext context) {
final l10n = l10nLocalizations(context)!;
@ -127,8 +128,10 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
AsyncValue<MPages?>? _getManga;
int _length = 0;
bool _isFiltering = false;
late final supportsLatest = ref.watch(supportsLatestProvider(source: source));
late final filterList = getFilterList(source: source);
late final supportsLatest = isLocal
? true
: ref.watch(supportsLatestProvider(source: source));
late final filterList = isLocal ? [] : getFilterList(source: source);
@override
Widget build(BuildContext context) {
if (_selectedIndex == 2 && (_isSearch && _query.isNotEmpty) ||
@ -161,7 +164,15 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("${source.name}"),
Text(
isLocal
? "${source.name}"
: "${context.l10n.local_source} ${source.itemType == ItemType.manga
? context.l10n.manga
: source.itemType == ItemType.anime
? context.l10n.anime
: context.l10n.novel}",
),
source.notes != null && source.notes!.isNotEmpty
? SizedBox(
height: 20,

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/modules/library/providers/file_scanner.dart';
import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:numberpicker/numberpicker.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class DownloadsScreen extends ConsumerStatefulWidget {
const DownloadsScreen({super.key});
@ -18,6 +20,7 @@ class _DownloadsScreenState extends ConsumerState<DownloadsScreen> {
final saveAsCBZArchiveState = ref.watch(saveAsCBZArchiveStateProvider);
final onlyOnWifiState = ref.watch(onlyOnWifiStateProvider);
final concurrentDownloads = ref.watch(concurrentDownloadsStateProvider);
final localFolders = ref.watch(localFoldersStateProvider);
final l10n = l10nLocalizations(context);
return Scaffold(
appBar: AppBar(title: Text(l10n!.downloads)),
@ -106,6 +109,14 @@ class _DownloadsScreenState extends ConsumerState<DownloadsScreen> {
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
),
SuperListView.builder(
itemCount: localFolders.length,
padding: const EdgeInsets.only(bottom: 100),
itemBuilder: (context, index) {
final folder = localFolders[index];
return Text(folder);
},
),
],
),
),

View file

@ -1,5 +1,9 @@
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -12,6 +16,26 @@ Future<MPages?> getLatestUpdates(
required Source source,
required int page,
}) async {
if (source.name == "local" && source.lang == "") {
final result =
(await isar.mangas
.filter()
.group(
(q) => q
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local"),
)
.sortByDateAddedDesc()
.offset(page * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))
.toList();
return MPages(list: result, hasNextPage: true);
}
return getExtensionService(
source,
ref.read(androidProxyServerStateProvider),

View file

@ -6,7 +6,7 @@ part of 'get_latest_updates.dart';
// RiverpodGenerator
// **************************************************************************
String _$getLatestUpdatesHash() => r'fd4ece1d796e079a469e5f80f456ee821ff0bc03';
String _$getLatestUpdatesHash() => r'b6a138d2e2202fc8fec29c1e4d31c4a4ddb8d848';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -1,5 +1,9 @@
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -12,6 +16,26 @@ Future<MPages?> getPopular(
required Source source,
required int page,
}) async {
if (source.name == "local" && source.lang == "") {
final result =
(await isar.mangas
.filter()
.group(
(q) => q
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local"),
)
.sortByName()
.offset(page * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))
.toList();
return MPages(list: result, hasNextPage: true);
}
return getExtensionService(
source,
ref.read(androidProxyServerStateProvider),

View file

@ -6,7 +6,7 @@ part of 'get_popular.dart';
// RiverpodGenerator
// **************************************************************************
String _$getPopularHash() => r'5fd933ce7e2b9c2dd113b7642ed54c1a1196f638';
String _$getPopularHash() => r'85152c70d6873dbf9c7f1b8a7f9d3846c53b9863';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -1,5 +1,9 @@
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -14,6 +18,26 @@ Future<MPages?> search(
required int page,
required List<dynamic> filterList,
}) async {
if (source.name == "local" && source.lang == "") {
final result =
(await isar.mangas
.filter()
.group(
(q) => q
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local"),
)
.nameContains(query, caseSensitive: false)
.offset(page * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))
.toList();
return MPages(list: result, hasNextPage: true);
}
return getExtensionService(
source,
ref.read(androidProxyServerStateProvider),

View file

@ -6,7 +6,7 @@ part of 'search.dart';
// RiverpodGenerator
// **************************************************************************
String _$searchHash() => r'b08d5a4b6e7d285830af7e5388b06fa61f175ede';
String _$searchHash() => r'e23c6dd56549ffdfc89b5fcfa43719d90ca1760e';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -1,6 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -14,6 +18,26 @@ Future<MPages?> search(
required int page,
required List<dynamic> filterList,
}) async {
if (source.name == "local" && source.lang == "") {
final result =
(await isar.mangas
.filter()
.group(
(q) => q
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local"),
)
.nameContains(query, caseSensitive: false)
.offset(page * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))
.toList();
return MPages(list: result, hasNextPage: true);
}
return getExtensionService(
source,
ref.read(androidProxyServerStateProvider),

View file

@ -6,7 +6,7 @@ part of 'search_.dart';
// RiverpodGenerator
// **************************************************************************
String _$searchHash() => r'b08d5a4b6e7d285830af7e5388b06fa61f175ede';
String _$searchHash() => r'e23c6dd56549ffdfc89b5fcfa43719d90ca1760e';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -6,7 +6,7 @@ part of 'trakt_tv.dart';
// RiverpodGenerator
// **************************************************************************
String _$traktTvHash() => r'd852a7d96511637bf565cbcf6e958397740158fd';
String _$traktTvHash() => r'269f0b865c39188f083dbc7dcad9652ee9e31efa';
/// Copied from Dart SDK
class _SystemHash {