add support to convert downloaded m3u8 files to mp4

This commit is contained in:
kodjomoustapha 2024-09-20 15:07:12 +01:00
parent e180919551
commit 6e1f6c1375
11 changed files with 186 additions and 162 deletions

View file

@ -28,7 +28,6 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart';
import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart';
// ignore: depend_on_referenced_packages
import 'package:path/path.dart' as path;
class AnimePlayerView extends riv.ConsumerStatefulWidget {
@ -41,13 +40,11 @@ class AnimePlayerView extends riv.ConsumerStatefulWidget {
class _AnimePlayerViewState extends riv.ConsumerState<AnimePlayerView> {
String? _infoHash;
HttpServer? _httpServer;
@override
void dispose() {
if (_infoHash != null) {
MTorrentServer().removeTorrent(_infoHash);
}
_httpServer?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
super.dispose();
@ -60,9 +57,8 @@ class _AnimePlayerViewState extends riv.ConsumerState<AnimePlayerView> {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return serversData.when(
data: (data) {
final (videos, isLocal, infoHash, httpServer) = data;
final (videos, isLocal, infoHash) = data;
_infoHash = infoHash;
_httpServer = httpServer;
if (videos.isEmpty &&
!(widget.episode.manga.value!.isLocalArchive ?? false)) {
return Scaffold(

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:mangayomi/models/page.dart';
import 'package:mangayomi/services/background_downloader/background_downloader.dart';
import 'package:isar/isar.dart';
@ -51,7 +52,23 @@ Future<List<PageUrl>> downloadChapter(
bool hasM3U8File = false;
bool nonM3U8File = false;
M3u8Downloader? m3u8Downloader;
String? tsKey;
Uint8List? tsKey;
Uint8List? tsIv;
int? m3u8MediaSequence;
Future<void> processConvert() async {
if (hasM3U8File) {
await m3u8Downloader?.mergeTsToMp4(
"${path!.path}$chapterName.mp4", "${path.path}$chapterName");
} else {
if (ref.watch(saveAsCBZArchiveStateProvider)) {
await ref.watch(convertToCBZProvider(path!.path, mangaDir!.path,
chapter.name!, pageUrls.map((e) => e.url).toList())
.future);
}
}
}
void savePageUrls() {
final settings = isar.settings.getSync(227)!;
List<ChapterPageurls>? chapterPageUrls = [];
@ -85,10 +102,7 @@ Future<List<PageUrl>> downloadChapter(
}
});
} else {
ref
.read(
getVideoListProvider(episode: chapter, ignoreM3u8File: true).future)
.then((value) async {
ref.read(getVideoListProvider(episode: chapter).future).then((value) async {
final m3u8Urls = value.$1
.where((element) =>
element.originalUrl.endsWith(".m3u8") ||
@ -107,7 +121,8 @@ Future<List<PageUrl>> downloadChapter(
m3u8Url: videosUrls.first.url,
downloadDir: "${path!.path}$chapterName",
headers: videosUrls.first.headers ?? {});
(tsList, tsKey) = await m3u8Downloader!.getTsList();
(tsList, tsKey, tsIv, m3u8MediaSequence) =
await m3u8Downloader!.getTsList();
}
pageUrls = hasM3U8File
? [...tsList.map((e) => PageUrl(e.url))]
@ -179,6 +194,7 @@ Future<List<PageUrl>> downloadChapter(
final file = File(
"${tempDir.path}/Mangayomi/$finalPath/${padIndex(index + 1)}.jpg");
if (file.existsSync()) {
Directory(path.path).createSync(recursive: true);
await file.copy("${path.path}${padIndex(index + 1)}.jpg");
await file.delete();
} else {
@ -228,6 +244,7 @@ Future<List<PageUrl>> downloadChapter(
"${tempDir.path}/Mangayomi/$finalPath/$chapterName/TS_${index + 1}.ts");
final file = File("${path.path}$chapterName/TS_${index + 1}.ts");
if (tempFile.existsSync()) {
Directory("${path.path}$chapterName").createSync(recursive: true);
await tempFile
.copy("${path.path}$chapterName/TS_${index + 1}.ts");
await tempFile.delete();
@ -284,6 +301,7 @@ Future<List<PageUrl>> downloadChapter(
}
if (tasks.isEmpty && pageUrls.isNotEmpty) {
await processConvert();
savePageUrls();
final download = Download(
succeeded: 0,
@ -299,23 +317,16 @@ Future<List<PageUrl>> downloadChapter(
isar.downloads.putSync(download..chapter.value = chapter);
});
} else {
if (hasM3U8File) {
await Directory("${path.path}$chapterName").create(recursive: true);
}
savePageUrls();
await FileDownloader().downloadBatch(
tasks,
batchProgressCallback: (succeeded, failed) async {
if (isManga || hasM3U8File) {
if (succeeded == tasks.length) {
if (hasM3U8File) {
} else {
if (ref.watch(saveAsCBZArchiveStateProvider)) {
await ref.watch(convertToCBZProvider(
path!.path,
mangaDir.path,
chapter.name!,
pageUrls.map((e) => e.url).toList())
.future);
}
}
await processConvert();
}
bool isEmpty = isar.downloads
.filter()
@ -384,9 +395,13 @@ Future<List<PageUrl>> downloadChapter(
if (progress == 1.0) {
final file = File(
"${tempDir.path}/${taskProgress.task.directory}/${taskProgress.task.filename}");
await file.copy(
final newFile = await file.copy(
"${path!.path}${hasM3U8File ? "$chapterName/" : ""}${taskProgress.task.filename}");
await file.delete();
if (hasM3U8File) {
await m3u8Downloader?.processBytes(
newFile, tsKey, tsIv, m3u8MediaSequence);
}
}
},
);

View file

@ -6,7 +6,7 @@ part of 'download_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$downloadChapterHash() => r'c5e5cf07f28a558d6dc827a1e08933125ef52261';
String _$downloadChapterHash() => r'3209fc6c18197b4b2337d6fa9859474ec144694b';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -60,7 +60,7 @@ class DownloadQueueScreen extends ConsumerWidget {
groupSeparatorBuilder: (String groupByValue) {
final sourceQueueLength = entries
.where((element) =>
element.chapter.value!.manga.value!.source! ==
(element.chapter.value?.manga.value?.source ?? "") ==
groupByValue)
.toList()
.length;
@ -87,7 +87,8 @@ class DownloadQueueScreen extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
element.chapter.value!.manga.value!.name!,
element.chapter.value?.manga.value?.name ??
"",
style: const TextStyle(fontSize: 16),
),
Text(
@ -97,7 +98,7 @@ class DownloadQueueScreen extends ConsumerWidget {
],
),
Text(
element.chapter.value!.name!,
element.chapter.value?.name ?? "",
style: const TextStyle(fontSize: 13),
),
const SizedBox(
@ -151,12 +152,12 @@ class DownloadQueueScreen extends ConsumerWidget {
} else if (value.toString() == 'CancelAll') {
final chapterIds = entries
.where((e) =>
e.chapter.value!.manga.value!.name ==
element.chapter.value!.manga.value!
.name &&
e.chapter.value!.manga.value!.source ==
element.chapter.value!.manga.value!
.source)
e.chapter.value?.manga.value?.name ==
element.chapter.value?.manga.value
?.name &&
e.chapter.value?.manga.value?.source ==
element.chapter.value?.manga.value
?.source)
.map((e) => e.chapterId)
.toList();
for (var chapterId in chapterIds) {

View file

@ -6,7 +6,6 @@ import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/models/video.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/m3u8/m3u8_server.dart';
import 'package:mangayomi/services/torrent_server.dart';
import 'package:mangayomi/utils/utils.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart';
@ -14,27 +13,18 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'get_video_list.g.dart';
@riverpod
Future<(List<Video>, bool, String?, HttpServer?)> getVideoList(
GetVideoListRef ref,
{required Chapter episode,
bool ignoreM3u8File = false}) async {
Future<(List<Video>, bool, String?)> getVideoList(GetVideoListRef ref,
{required Chapter episode}) async {
final storageProvider = StorageProvider();
final mangaDirectory = await storageProvider.getMangaMainDirectory(episode);
final isLocalArchive = episode.manga.value!.isLocalArchive! &&
episode.manga.value!.source != "torrent";
final mp4animePath =
"${mangaDirectory!.path}${episode.name!.replaceForbiddenCharacters(' ')}.mp4";
final episodeFolderPath =
"${mangaDirectory.path}${episode.name!.replaceForbiddenCharacters(' ')}";
if (await File(mp4animePath).exists() || isLocalArchive) {
final path = isLocalArchive ? episode.archivePath : mp4animePath;
return (
[Video(path!, episode.name!, path, subtitles: [])],
true,
null,
null
);
return ([Video(path!, episode.name!, path, subtitles: [])], true, null);
}
final source =
getSource(episode.manga.value!.lang!, episode.manga.value!.source!);
@ -42,20 +32,9 @@ Future<(List<Video>, bool, String?, HttpServer?)> getVideoList(
if (source?.isTorrent ?? false || episode.manga.value!.source == "torrent") {
final (videos, infohash) = await MTorrentServer()
.getTorrentPlaylist(episode.url, episode.archivePath);
return (videos, false, infohash, null);
}
if (File("$episodeFolderPath/index.m3u8").existsSync() && !ignoreM3u8File) {
const indexUrl = "http://localhost:3000/index.m3u8";
final httpServer = await m3u8Server(
m3u8Content: File("$episodeFolderPath/index.m3u8").readAsStringSync(),
episodeFolderPath: episodeFolderPath);
return (
[Video(indexUrl, episode.name!, indexUrl)],
false,
null,
httpServer
);
return (videos, false, infohash);
}
List<Video> list = [];
if (source?.sourceCodeLanguage == SourceCodeLanguage.dart) {
list = await DartExtensionService(source).getVideoList(episode.url!);
@ -68,5 +47,5 @@ Future<(List<Video>, bool, String?, HttpServer?)> getVideoList(
videos.add(video);
}
}
return (videos, false, null, null);
return (videos, false, null);
}

View file

@ -6,7 +6,7 @@ part of 'get_video_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$getVideoListHash() => r'2002f381edbe8c3c5e8a00826b3d9aaf49410e57';
String _$getVideoListHash() => r'e5cc579c492bdf4cd226b93c42766599cece4cd6';
/// Copied from Dart SDK
class _SystemHash {
@ -35,18 +35,16 @@ const getVideoListProvider = GetVideoListFamily();
/// See also [getVideoList].
class GetVideoListFamily
extends Family<AsyncValue<(List<Video>, bool, String?, HttpServer?)>> {
extends Family<AsyncValue<(List<Video>, bool, String?)>> {
/// See also [getVideoList].
const GetVideoListFamily();
/// See also [getVideoList].
GetVideoListProvider call({
required Chapter episode,
bool ignoreM3u8File = false,
}) {
return GetVideoListProvider(
episode: episode,
ignoreM3u8File: ignoreM3u8File,
);
}
@ -56,7 +54,6 @@ class GetVideoListFamily
) {
return call(
episode: provider.episode,
ignoreM3u8File: provider.ignoreM3u8File,
);
}
@ -76,17 +73,15 @@ class GetVideoListFamily
}
/// See also [getVideoList].
class GetVideoListProvider extends AutoDisposeFutureProvider<
(List<Video>, bool, String?, HttpServer?)> {
class GetVideoListProvider
extends AutoDisposeFutureProvider<(List<Video>, bool, String?)> {
/// See also [getVideoList].
GetVideoListProvider({
required Chapter episode,
bool ignoreM3u8File = false,
}) : this._internal(
(ref) => getVideoList(
ref as GetVideoListRef,
episode: episode,
ignoreM3u8File: ignoreM3u8File,
),
from: getVideoListProvider,
name: r'getVideoListProvider',
@ -98,7 +93,6 @@ class GetVideoListProvider extends AutoDisposeFutureProvider<
allTransitiveDependencies:
GetVideoListFamily._allTransitiveDependencies,
episode: episode,
ignoreM3u8File: ignoreM3u8File,
);
GetVideoListProvider._internal(
@ -109,16 +103,13 @@ class GetVideoListProvider extends AutoDisposeFutureProvider<
required super.debugGetCreateSourceHash,
required super.from,
required this.episode,
required this.ignoreM3u8File,
}) : super.internal();
final Chapter episode;
final bool ignoreM3u8File;
@override
Override overrideWith(
FutureOr<(List<Video>, bool, String?, HttpServer?)> Function(
GetVideoListRef provider)
FutureOr<(List<Video>, bool, String?)> Function(GetVideoListRef provider)
create,
) {
return ProviderOverride(
@ -131,51 +122,43 @@ class GetVideoListProvider extends AutoDisposeFutureProvider<
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
episode: episode,
ignoreM3u8File: ignoreM3u8File,
),
);
}
@override
AutoDisposeFutureProviderElement<(List<Video>, bool, String?, HttpServer?)>
AutoDisposeFutureProviderElement<(List<Video>, bool, String?)>
createElement() {
return _GetVideoListProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is GetVideoListProvider &&
other.episode == episode &&
other.ignoreM3u8File == ignoreM3u8File;
return other is GetVideoListProvider && other.episode == episode;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, episode.hashCode);
hash = _SystemHash.combine(hash, ignoreM3u8File.hashCode);
return _SystemHash.finish(hash);
}
}
mixin GetVideoListRef
on AutoDisposeFutureProviderRef<(List<Video>, bool, String?, HttpServer?)> {
on AutoDisposeFutureProviderRef<(List<Video>, bool, String?)> {
/// The parameter `episode` of this provider.
Chapter get episode;
/// The parameter `ignoreM3u8File` of this provider.
bool get ignoreM3u8File;
}
class _GetVideoListProviderElement extends AutoDisposeFutureProviderElement<
(List<Video>, bool, String?, HttpServer?)> with GetVideoListRef {
class _GetVideoListProviderElement
extends AutoDisposeFutureProviderElement<(List<Video>, bool, String?)>
with GetVideoListRef {
_GetVideoListProviderElement(super.provider);
@override
Chapter get episode => (origin as GetVideoListProvider).episode;
@override
bool get ignoreM3u8File => (origin as GetVideoListProvider).ignoreM3u8File;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -163,7 +163,7 @@ class LoggerInterceptor extends InterceptorContract {
required BaseRequest request,
}) async {
Logger.add(LoggerLevel.info,
'----- Request -----\n${request.toString()}\nheader: ${request.headers.toString()}');
'----- Request -----\n${request.toString()}\nheaders: ${request.headers.toString()}');
return request;
}

View file

@ -1,8 +1,14 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:io';
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart';
import 'package:path/path.dart' as path;
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:convert/convert.dart';
class TsInfo {
final String name;
@ -19,23 +25,37 @@ class M3u8Downloader {
required this.downloadDir,
required this.headers});
Future<(List<TsInfo>, String?)> getTsList() async {
String? key;
Future<(List<TsInfo>, Uint8List?, Uint8List?, int?)> getTsList() async {
Uint8List? key;
Uint8List? iv;
int? mediaSequence;
final uri = Uri.parse(m3u8Url);
final m3u8Host = "${uri.scheme}://${uri.host}${path.dirname(uri.path)}";
final m3u8Body = await _getM3u8Body(m3u8Url);
final tsList = _parseTsList(m3u8Host, m3u8Body);
mediaSequence = _extractMediaSequence(m3u8Body);
if (kDebugMode) {
print("Total TS files to download: ${tsList.length}");
}
String? tsKey = await getM3u8Key(m3u8Body);
final (tsKey, tsIv) = await _getM3u8KeyAndIv(m3u8Body);
if (tsKey?.isNotEmpty ?? false) {
if (kDebugMode) {
print("TS Key: $tsKey");
}
key = tsKey;
}
return (tsList, key);
if (tsIv != null) {
if (kDebugMode) {
print("TS Iv: $tsIv");
}
iv = Uint8List.fromList(hex.decode(tsIv.replaceFirst("0x", "")));
}
if (mediaSequence != null) {
if (kDebugMode) {
print("Media sequence: $mediaSequence");
}
}
return (tsList, key, iv, mediaSequence);
}
Future<String> _getM3u8Body(
@ -52,50 +72,122 @@ class M3u8Downloader {
List<TsInfo> _parseTsList(String host, String body) {
final lines = body.split("\n");
final tsList = <TsInfo>[];
List<TsInfo> tsList = [];
int index = 0;
String allText = "";
for (final line in lines) {
if (!line.startsWith("#") && line.isNotEmpty) {
index++;
final tsUrl = line.startsWith("http")
? line
: "$host/${line.replaceFirst("/", "")}";
allText += "http://localhost:3000/TS_$index.ts\n";
tsList.add(TsInfo("TS_$index", tsUrl));
} else {
allText += "$line\n";
}
}
Directory(downloadDir).createSync(recursive: true);
File("$downloadDir/index.m3u8").writeAsStringSync(allText);
return tsList;
}
Future<String?> getM3u8Key(String m3u8Body) async {
Future<(Uint8List?, String?)> _getM3u8KeyAndIv(String m3u8Body) async {
final uri = Uri.parse(m3u8Url);
final m3u8Host = "${uri.scheme}://${uri.host}${path.dirname(uri.path)}";
final lines = m3u8Body.split("\n");
for (final line in lines) {
if (line.contains("#EXT-X-KEY")) {
final keyUrl = _extractKeyUrl(m3u8Host, line);
final response =
await MClient.httpClient().get(Uri.parse(keyUrl), headers: headers);
if (response.statusCode == 200) {
return response.body;
final (keyUrl, iv) = _extractKeyAttributes(line, m3u8Host);
if (keyUrl != null) {
final response = await MClient.httpClient()
.get(Uri.parse(keyUrl), headers: headers);
if (response.statusCode == 200) {
return (response.bodyBytes, iv);
}
} else {
break;
}
}
}
return (null, null);
}
(String?, String?) _extractKeyAttributes(String content, String host) {
final keyPattern = RegExp(
r'#EXT-X-KEY:METHOD=AES-128(?:,URI="([^"]+)")?(?:,IV=0x([A-F0-9]+))?',
caseSensitive: false);
final match = keyPattern.firstMatch(content);
String? uri = match?.group(1);
if (uri != null) {
if (!uri.contains("http")) {
uri = "$host/$uri";
}
}
final iv = match?.group(2);
return (uri, iv);
}
Uint8List _aesDecrypt(int sequence, Uint8List encrypted, Uint8List key,
{Uint8List? iv}) {
if (iv == null) {
iv = Uint8List(16);
ByteData.view(iv.buffer).setUint64(8, sequence);
}
final encrypter = encrypt.Encrypter(
encrypt.AES(encrypt.Key(key), mode: encrypt.AESMode.cbc));
try {
final decrypted = encrypter.decryptBytes(encrypt.Encrypted(encrypted),
iv: encrypt.IV(iv));
return Uint8List.fromList(decrypted);
} catch (e) {
throw ArgumentError('Decryption failed: $e');
}
}
int? _extractMediaSequence(String content) {
final lines = content.split('\n');
for (var line in lines) {
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE')) {
final sequenceStr = line.substringAfter(':');
return int.tryParse(sequenceStr.trim());
}
}
return null;
}
String _extractKeyUrl(String host, String line) {
final uriPos = line.indexOf("URI");
final quotationMarkPos = line.lastIndexOf("\"");
var keyUrl = line.substring(uriPos, quotationMarkPos).split("\"")[1];
if (!line.contains("http")) {
keyUrl = "$host/$keyUrl";
Future<void> mergeTsToMp4(String fileName, String directory) async {
await Isolate.run(() async {
List<String> tsPathList = [];
final outFile = File(fileName).openWrite();
final dir = Directory(directory);
await for (var entity in dir.list()) {
if (entity is File && entity.path.endsWith('.ts')) {
tsPathList.add(entity.path);
}
}
tsPathList.sort((a, b) =>
int.parse(a.substringAfter("TS_").substringBefore(".")).compareTo(
int.parse(b.substringAfter("TS_").substringBefore("."))));
for (var path in tsPathList) {
final bytes = await File(path).readAsBytes();
outFile.add(bytes);
}
await outFile.flush();
await outFile.close();
await dir.delete(recursive: true);
});
}
Future<void> processBytes(File newFile, Uint8List? tsKey, Uint8List? tsIv,
int? m3u8Sequence) async {
Uint8List bytes = await newFile.readAsBytes();
if (tsKey != null) {
final index =
int.parse(newFile.path.substringAfter("TS_").substringBefore("."));
bytes = _aesDecrypt((m3u8Sequence ?? 1) + (index - 1), bytes, tsKey,
iv: tsIv);
}
return keyUrl;
await newFile.writeAsBytes(bytes);
}
}

View file

@ -1,41 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
Future<HttpServer> m3u8Server(
{required String m3u8Content, required String episodeFolderPath}) async {
final handler =
const Pipeline().addMiddleware(logRequests()).addHandler((request) {
return _handleRequest(request, m3u8Content, episodeFolderPath);
});
final server = await io.serve(handler, 'localhost', 3000);
if (kDebugMode) {
print(
'[INFO-M3U8-SERVER] Listening on http://${server.address.host}:${server.port}');
}
return server;
}
Response _handleRequest(
Request request, String m3u8Content, String episodeFolderPath) {
if (request.url.path == 'index.m3u8') {
return Response.ok(m3u8Content,
headers: {'Content-Type': 'application/vnd.apple.mpegurl'});
}
if (request.url.path.endsWith('.ts')) {
final tsFilePath = '$episodeFolderPath/${request.url.pathSegments.last}';
final file = File(tsFilePath);
if (file.existsSync()) {
return Response.ok(file.openRead(), headers: {
'Content-Type': 'video/MP2T',
});
} else {
return Response.notFound('File not found');
}
}
return Response.notFound('Not exist');
}

View file

@ -58,10 +58,10 @@ packages:
dependency: transitive
description:
name: asn1lib
sha256: "2ca377ad4d677bbadca278e0ba4da4e057b80a10b927bfc8f7d8bda8fe2ceb75"
sha256: "6b151826fcc95ff246cd219a0bf4c753ea14f4081ad71c61939becf3aba27f70"
url: "https://pub.dev"
source: hosted
version: "1.5.4"
version: "1.5.5"
async:
dependency: transitive
description:
@ -371,10 +371,10 @@ packages:
dependency: "direct main"
description:
name: extended_image
sha256: c7b628bdbeb398bdd824cfc9d521f18e04b63ad2af811d182e885d3ae4ef45de
sha256: "75235b2cf90de7663640c2da43b0549fc1f373340b7ee925696e92e8ec55c4db"
url: "https://pub.dev"
source: hosted
version: "8.2.3"
version: "8.2.4"
extended_image_library:
dependency: transitive
description:
@ -972,10 +972,10 @@ packages:
dependency: "direct overridden"
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@ -1360,7 +1360,7 @@ packages:
source: hosted
version: "4.0.0"
shelf:
dependency: "direct main"
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12

View file

@ -74,7 +74,6 @@ dependencies:
rust_lib_mangayomi:
path: rust_builder
pseudom: ^1.0.1
shelf: ^1.4.2
path: ^1.9.0
freezed_annotation: ^2.0.0