New delete options & storage visibility

- Added react-native-progress dependency
- Added bottom sheet to downloads page to handle actions for deleting items by type
- Added ability to long press to delete a single series
- Added ability to delete by season
- Refactored delete helpers in DownloadProvider.tsx
- Display storage usage inside downloads & settings page
- Fixed Delete all downloaded files from delting user data in mmkv
This commit is contained in:
herrrta
2024-12-02 21:42:13 -05:00
parent 69ffdc2ddf
commit b73c29221a
8 changed files with 368 additions and 183 deletions

View File

@@ -1,12 +1,13 @@
import {Text} from "@/components/common/Text";
import {useDownload} from "@/providers/DownloadProvider";
import {router, useLocalSearchParams, useNavigation} from "expo-router";
import React, {useEffect, useMemo, useState} from "react";
import {ScrollView, View} from "react-native";
import React, {useCallback, useEffect, useMemo, useState} from "react";
import {ScrollView, TouchableOpacity, View} from "react-native";
import {EpisodeCard} from "@/components/downloads/EpisodeCard";
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
import {storage} from "@/utils/mmkv";
import {Ionicons} from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
@@ -17,7 +18,7 @@ export default function page() {
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>({});
const {downloadedFiles} = useDownload();
const {downloadedFiles, deleteItems} = useDownload();
const series = useMemo(() => {
try {
@@ -64,6 +65,10 @@ export default function page() {
}
}, [series]);
const deleteSeries = useCallback(
async () => deleteItems(groupBySeason),
[groupBySeason]
);
return (
<>
{series.length > 0 && <View className="my-4 flex flex-row items-center justify-start">
@@ -78,9 +83,16 @@ export default function page() {
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="flex flex-row items-center justify-between w-72">
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={22} color="white"/>
</TouchableOpacity>
</View>
</View>
</View>}
<ScrollView key={seasonIndex}>
{groupBySeason.map((episode, index) => (

View File

@@ -6,17 +6,24 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import {useNavigation, useRouter} from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
@@ -46,107 +53,160 @@ export default function page() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity>
)
})
}, [downloadedFiles]);
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -2,20 +2,22 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
import {Alert, ScrollView, View} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload();
const { deleteAllFiles, getAppSizeUsage } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -28,6 +30,18 @@ export default function settings() {
const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize"],
queryFn: async () => {
const app = await getAppSizeUsage()
const remaining = await FileSystem.getFreeDiskStorageAsync()
const total = await FileSystem.getTotalDiskCapacityAsync()
return {app, remaining, total, used: (total - remaining) / total}
}
})
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
@@ -57,6 +71,27 @@ export default function settings() {
);
};
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
};
return (
<ScrollView
contentContainerStyle={{
@@ -81,6 +116,9 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<View>
@@ -92,42 +130,33 @@ export default function settings() {
<SettingToggles />
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
<Button
color="red"
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>

BIN
bun.lockb

Binary file not shown.

View File

@@ -2,9 +2,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {useEffect, useMemo, useState} from "react";
import {Text} from "@/components/common/Text";
import useDownloadHelper from "@/utils/download";
import {useDownload} from "@/providers/DownloadProvider";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import {TextProps} from "react-native";
interface DownloadSizeProps {
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
@@ -13,7 +14,7 @@ interface DownloadSizes {
itemsNeedingSize: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items }) => {
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items, ...props }) => {
const { downloadedFiles, saveDownloadedItemInfo } = useDownload();
const { getDownloadSize } = useDownloadHelper();
const [size, setSize] = useState<string | undefined>();
@@ -53,17 +54,9 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({ items }) => {
return size
}, [size])
const bytesToReadable = (bytes: number) => {
const gb = bytes / 1e+9;
if (gb >= 1)
return `${gb.toFixed(2)} GB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
return (
<>
<Text className="text-xs text-neutral-500">{sizeText}</Text>
<Text className="text-xs text-neutral-500" {...props}>{sizeText}</Text>
</>
);
};

View File

@@ -1,20 +1,49 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {TouchableOpacity, View} from "react-native";
import { Text } from "../common/Text";
import React, {useEffect, useMemo, useState} from "react";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
options,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
}
);
}, [showActionSheetWithOptions, deleteSeries]);
return (
<TouchableOpacity onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}>
<TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image

View File

@@ -83,6 +83,7 @@
"react-native-ios-utilities": "^4.5.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",

View File

@@ -47,6 +47,8 @@ import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import {FileInfo} from "expo-file-system";
import * as Haptics from "expo-haptics";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
@@ -388,19 +390,20 @@ function useDownloadProvider() {
);
const deleteAllFiles = async (): Promise<void> => {
try {
await deleteLocalFiles();
removeDownloadedItemsFromStorage();
await cancelAllServerJobs();
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
toast.success("All files, folders, and jobs deleted successfully");
} catch (error) {
console.error("Failed to delete all files, folders, and jobs:", error);
Promise.all([
deleteLocalFiles(),
removeDownloadedItemsFromStorage(),
cancelAllServerJobs(),
queryClient.invalidateQueries({queryKey: ["downloadedItems"]}),
]).then(() =>
toast.success("All files, folders, and jobs deleted successfully")
).catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
}
});
};
const deleteLocalFiles = async (): Promise<void> => {
const forEveryDirectory = async (callback: (dir: FileInfo) => void) => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
@@ -408,25 +411,36 @@ function useDownloadProvider() {
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`;
const itemInfo = await FileSystem.getInfoAsync(itemPath);
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if (item == "mmkv")
continue
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (itemInfo.exists) {
if (itemInfo.isDirectory) {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
} else {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
callback(itemInfo)
}
}
}
const deleteLocalFiles = async (): Promise<void> => {
await forEveryDirectory((dir) => {
console.warn("Deleting file", dir.uri)
FileSystem.deleteAsync(dir.uri, {idempotent: true})
}
)
};
const removeDownloadedItemsFromStorage = (): void => {
try {
storage.delete("downloadedItems");
} catch (error) {
console.error("Failed to remove downloadedItems from storage:", error);
throw error;
}
const removeDownloadedItemsFromStorage = async () => {
// delete any saved images first
Promise.all([
deleteFileByType("Movie"),
deleteFileByType("Episode"),
]).then(() =>
storage.delete("downloadedItems")
).catch((reason) => {
console.error("Failed to remove downloadedItems from storage:", reason);
throw reason
})
};
const cancelAllServerJobs = async (): Promise<void> => {
@@ -434,7 +448,8 @@ function useDownloadProvider() {
throw new Error("No auth header available");
}
if (!settings?.optimizedVersionsServerUrl) {
throw new Error("No server URL configured");
console.error("No server URL configured");
return
}
const deviceId = await getOrSetDeviceId();
@@ -494,6 +509,41 @@ function useDownloadProvider() {
}
};
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(items.map(i => {
if (i.Id)
return deleteFile(i.Id)
return
})).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
)
}
const deleteFileByType = async (type: BaseItemDto['Type']) => {
await Promise.all(
downloadedFiles
?.filter(file => file.item.Type == type)
?.flatMap(file => {
const promises = [];
if (type == "Episode" && file.item.SeriesId)
promises.push(deleteFile(file.item.SeriesId))
promises.push(deleteFile(file.item.Id!))
return promises;
})
|| []
);
}
const getAppSizeUsage = async () => {
const sizes: number[] = [];
await forEveryDirectory(dir => {
if (dir.exists)
sizes.push(dir.size)
})
return sizes.reduce((sum, size) => sum + size, 0);
}
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
const downloadedItems = storage.getString("downloadedItems");
@@ -566,11 +616,14 @@ function useDownloadProvider() {
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
getAppSizeUsage
};
}
@@ -591,3 +644,11 @@ export function useDownload() {
}
return context;
}
export function bytesToReadable(bytes: number): string {
const gb = bytes / 1e+9;
if (gb >= 1)
return `${gb.toFixed(2)} GB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}