Users can now add Mangas/Animes to a **manually** created Mangayomi/local folder.
Feature as described:
```
App Home Location/
  local/
    Manga Title/
      cover.jpg (optional)
      Chapter 1/
        1.jpg
        ...
      Chapter 2.cbz
      ...
    Anime Title/
      cover.png (optional)
      Episode 1.mp4
      Episode 2.mkv
```

The folder (if exist) will be scanned once per app start.

**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
```
This commit is contained in:
NBA2K1 2025-05-02 13:44:37 +02:00
parent 4010d019ae
commit 1014c71f5c
5 changed files with 425 additions and 3 deletions

View file

@ -22,6 +22,7 @@ import 'package:mangayomi/l10n/generated/app_localizations.dart';
import 'package:mangayomi/src/rust/frb_generated.dart';
import 'package:mangayomi/utils/url_protocol/api.dart';
import 'package:mangayomi/modules/more/settings/appearance/providers/theme_provider.dart';
import 'package:mangayomi/modules/library/providers/file_scanner.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart';
@ -54,10 +55,10 @@ void main(List<String> args) async {
isar = await StorageProvider().initDB(null, inspector: kDebugMode);
runApp(const ProviderScope(child: MyApp()));
unawaited(postLaunchInit()); // Defer non-essential async operations
unawaited(_postLaunchInit()); // Defer non-essential async operations
}
Future<void> postLaunchInit() async {
Future<void> _postLaunchInit() async {
await StorageProvider().requestPermission();
await StorageProvider().deleteBtDirectory();
}
@ -79,6 +80,7 @@ class _MyAppState extends ConsumerState<MyApp> {
super.initState();
initializeDateFormatting();
_initDeepLinks();
unawaited(ref.read(scanLocalLibraryProvider.future));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (ref.read(clearChapterCacheOnAppLaunchStateProvider)) {

View file

@ -0,0 +1,340 @@
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: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
import 'package:mangayomi/models/chapter.dart'; // Has Chapter model with archivePath
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Riverpod state management
import 'package:mangayomi/providers/storage_provider.dart'; // Provides storage directory selection
import 'package:riverpod_annotation/riverpod_annotation.dart'; // Annotations for code generation
part 'file_scanner.g.dart';
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
/// **Folder structure:**
/// ```
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// ```
/// **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
/// ```
@riverpod
Future<void> scanLocalLibrary(Ref ref) async {
// Get /local directory
final localDir = await _getLocalLibrary();
// Don't do anything if /local doesn't exist
if (localDir == null || !await localDir.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()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
.findAll();
final mangaMap = {for (var m in existingMangas) _getRelativePath(m.link!): m};
// Fetch all chapters for existing mangas
final existingMangaIds = existingMangas.map((m) => m.id);
final existingChapters =
await isar.chapters
.filter()
.anyOf(existingMangaIds, (q, id) => q.mangaIdEqualTo(id))
.findAll();
// Map where the key is manga ID and the value is a set of chapter paths.
final chaptersMap = <int, Set<String>>{};
// Add manga.Ids with all the corresponding relative! paths (Manga/Chapter)
for (var chap in existingChapters) {
String path = _getRelativePath(chap.archivePath!);
// For the given manga ID, add the path to its associated set.
// If there's no entry for the manga ID yet, create a new empty set.
chaptersMap.putIfAbsent(chap.mangaId!, () => <String>{}).add(path);
}
// Collect all chapter paths chaptersMap into a single set for easy lookup.
final existingPaths = chaptersMap.values.expand((s) => s).toSet();
List<Manga> processedMangas = <Manga>[];
final List<List<dynamic>> newChapters = [];
// If newMangas > 0, save all collected Mangas in library first to get a Manga ID
int newMangas = 0;
/// helper function to add chapters to newChapters list
void addNewChapters(List<FileSystemEntity> items, bool imageFolder) {
for (final chapter in items) {
final relPath = _getRelativePath(chapter.path).trim();
// Skip if the relative path is empty (invalid entry).
if (relPath.isEmpty) continue;
if (!existingPaths.contains(relPath)) {
newChapters.add([chapter.path, imageFolder]);
existingPaths.add(relPath);
}
}
}
// Iterate over each sub-directory (each representing a title, Manga or Anime)
await for (final folder in localDir.list()) {
if (folder is! Directory) continue;
final title = p.basename(folder.path); // Anime/Manga title
String relativePath = _getRelativePath(folder.path);
// List all folders and files inside a Manga/Anime title
final children = await folder.list().toList();
final subDirs = children.whereType<Directory>().toList();
final files = children.whereType<File>().toList();
// Determine itemtype
final hasImagesFolders = subDirs.isNotEmpty;
final hasArchives = files.any((f) => _isArchive(f.path));
final hasVideos = files.any((f) => _isVideo(f.path));
late ItemType itemType;
if (hasImagesFolders || hasArchives) {
itemType = ItemType.manga;
} else if (hasVideos) {
itemType = ItemType.anime;
} else {
continue; // nothing to import from this folder
}
// Does Manga/Anime already exist in library?
bool existingManga = mangaMap.containsKey(relativePath);
// Create new Manga entry if it doesn't already exist
Manga manga;
if (existingManga) {
manga = mangaMap[relativePath]!;
} else {
manga = Manga(
favorite: true,
source: 'local',
author: '',
artist: '',
genre: [],
imageUrl: '',
lang: '',
link: folder.path,
name: title,
status: Status.unknown,
description: '',
isLocalArchive: true,
itemType: itemType,
dateAdded: dateNow,
lastUpdate: dateNow,
);
newMangas++;
}
// Detect a single image in item's root and use it as custom cover
final imageFiles = files.where((f) => _isImage(f.path)).toList();
if (imageFiles.length == 1) {
try {
final bytes = await File(imageFiles.first.path).readAsBytes();
final byteList = bytes.toList();
if (manga.customCoverImage != byteList) {
manga.customCoverImage = byteList;
manga.lastUpdate = dateNow;
}
} catch (e) {
BotToast.showText(text: "Error reading cover image: $e");
}
} else if (imageFiles.isEmpty && manga.customCoverImage != null) {
manga.customCoverImage = null;
}
processedMangas.add(manga);
// Scan chapters/episodes
if (hasImagesFolders) {
// Each subdirectory is a chapter
addNewChapters(subDirs, hasImagesFolders);
} // Possible that image folders and archives are mixed in one manga
if (hasArchives) {
// Each .cbz/.zip file is a chapter
final archives = files.where((f) => _isArchive(f.path)).toList();
addNewChapters(archives, false);
}
if (hasVideos) {
// Each .mp4 is an episode
final videos = files.where((f) => _isVideo(f.path)).toList();
addNewChapters(videos, false);
}
}
final changedMangas = <Manga>[];
for (var manga in processedMangas) {
if (manga.lastUpdate == dateNow) {
// Filter out items that haven't been changed
changedMangas.add(manga);
}
}
try {
// Save all new and changed items to the library
await isar.writeTxn(() async => await isar.mangas.putAll(changedMangas));
} catch (e) {
BotToast.showText(
text: "Database write error. Manga/Anime couldn't be saved: $e",
);
}
// If new Mangas have been added (no Id to save Chapters)
if (newMangas > 0) {
// Copy processedMangas
List<Manga> newAddedMangas = processedMangas;
// Fetch all existing mangas in library that are in /local (or \local)
final savedMangas =
await isar.mangas
.filter()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
.findAll();
// Save all retrieved Manga objects (now with id) matching the processedMangas list
newAddedMangas =
savedMangas
.where(
(m) => processedMangas.any(
(newManga) =>
_getRelativePath(newManga.link) == _getRelativePath(m.link),
),
)
.toList();
processedMangas.clear();
processedMangas = newAddedMangas;
}
final chaptersToSave = <Chapter>[];
int saveManga = 0; // Just to update the lastUpdate value of not new Mangas
final mangaByName = {for (var m in processedMangas) p.basename(m.link!): m};
// iterate through newChapters elements, which are: ["full_path/to/chapter1", "true"]
for (var pathBool in newChapters) {
final chapterPath = pathBool[0];
// pathBool[0] = first element of list (path)
// dirname = remove last part of path (chapter name), = "full_path/to"
// basename = remove everything except last (manga name) = "to"
final itemName = p.basename(p.dirname(chapterPath));
final manga = mangaByName[itemName];
if (manga != null) {
final chap = Chapter(
mangaId: manga.id,
name:
pathBool[1] // If Chapter is an image folder or archive/video
? p.basename(chapterPath)
: p.basenameWithoutExtension(chapterPath),
dateUpload: dateNow.toString(),
archivePath: chapterPath,
);
if (manga.lastUpdate != dateNow) {
manga.lastUpdate = dateNow;
saveManga++;
}
chaptersToSave.add(chap);
}
}
try {
if (saveManga > 0) {
// Just to update the lastUpdate value of not new Mangas
await isar.writeTxn(
() async => await isar.mangas.putAll(processedMangas),
);
}
} catch (e) {
BotToast.showText(text: "Error saving chapter/episode to library: $e");
}
try {
if (chaptersToSave.isNotEmpty) {
await isar.writeTxn(() async {
// insert chapters
await isar.chapters.putAll(chaptersToSave);
// for each one, set its link and save it
for (final chap in chaptersToSave) {
chap.manga.value = processedMangas.firstWhere(
(m) => m.id == chap.mangaId,
);
await chap.manga.save();
}
});
}
} catch (e) {
BotToast.showText(
text: "Database write error. Manga/Anime couldn't be saved: $e",
);
}
}
/// Returns the `/local` directory inside the app's default storage.
Future<Directory?> _getLocalLibrary() async {
try {
final dir = await StorageProvider().getDefaultDirectory();
return dir == null ? null : Directory(p.join(dir.path, 'local'));
} catch (e) {
BotToast.showText(text: "Error getting local library: $e");
return null;
}
}
/// Finds the String 'Mangayomi/local' and extract path after
/// ```
/// "C:\Users\user\Documents\Mangayomi\local\Manga 1\chapter1.zip"
/// becomes:
/// "Manga 1/chapter1.zip"
/// ```
String _getRelativePath(dir) {
String relativePath;
if (dir is Directory) {
relativePath = dir.path;
} else if (dir is String) {
relativePath = dir;
} else {
throw ArgumentError("Input must be a Directory or a String");
}
// Normalize path separators
relativePath = relativePath.replaceAll("\\", "/");
int index = relativePath.indexOf("Mangayomi/local");
if (index != -1) {
return relativePath.substring(index + "Mangayomi/local/".length);
} else {
return relativePath;
}
}
/// Returns if file is an image
bool _isImage(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.jpg' || ext == '.jpeg' || ext == '.png' || ext == '.webp';
}
/// Returns if file is an archive
bool _isArchive(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.cbz' || ext == '.zip' || ext == '.cbt' || ext == '.tar';
}
/// Returns if file is a video
bool _isVideo(String path) {
final ext = p.extension(path).toLowerCase();
const videoExtensions = {
'.mp4',
'.mov',
'.avi',
'.flv',
'.wmv',
'.mpeg',
'.mkv',
};
return videoExtensions.contains(ext);
}

View file

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_scanner.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$scanLocalLibraryHash() => r'8a80e2582c4abda500034c9e139cdb8be58942e7';
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
/// **Folder structure:**
/// ```
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// ```
/// **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
/// ```
///
/// Copied from [scanLocalLibrary].
@ProviderFor(scanLocalLibrary)
final scanLocalLibraryProvider = AutoDisposeFutureProvider<void>.internal(
scanLocalLibrary,
name: r'scanLocalLibraryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$scanLocalLibraryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ScanLocalLibraryRef = AutoDisposeFutureProviderRef<void>;
// 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

@ -12,7 +12,7 @@ class LocalArchive {
String? path;
}
enum LocalExtensionType { cbz, zip, cbt, tar }
enum LocalExtensionType { cbz, zip, cbt, tar, folder }
class LocalImage {
String? name;

View file

@ -102,6 +102,29 @@ bool _isArchiveFile(String path) {
}
LocalArchive _extractArchive(String path) {
// Folder of images?
if (Directory(path).existsSync()) {
final dir = Directory(path);
final pages =
dir.listSync().whereType<File>().where((f) => _isImageFile(f.path)).map(
(f) {
return LocalImage()
..image = f.readAsBytesSync()
..name = p.basename(f.path);
},
).toList()
..sort((a, b) => a.name!.compareTo(b.name!));
final localArchive =
LocalArchive()
..path = path
..extensionType = LocalExtensionType.folder
..name = p.basename(path)
..images = pages
..coverImage = pages.first.image;
return localArchive;
}
final localArchive =
LocalArchive()
..path = path
@ -144,6 +167,19 @@ LocalArchive _extractArchive(String path) {
(String, LocalExtensionType, Uint8List, String) _extractArchiveOnly(
String path,
) {
// If it's a directory, just read its images:
if (Directory(path).existsSync()) {
final dir = Directory(path);
final images =
dir
.listSync()
.whereType<File>()
.where((f) => _isImageFile(f.path))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
final cover = images.first.readAsBytesSync();
return (p.basename(path), LocalExtensionType.folder, cover, path);
}
final extensionType = setTypeExtension(
p.extension(path).replaceFirst('.', ''),
);