forked from Ninjalama/streamyfin_mirror
# Multiple Remux downloads
- Added stepper component - Disabled more download settings based on download method - refactored useRemuxHlsToMp4.ts to allow for multiple remux downloads
This commit is contained in:
@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||
<View className="space-y-2">
|
||||
{processes?.map((p) => (
|
||||
<DownloadCard key={p.id} process={p} />
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
FFmpegKit.cancel(Number(id));
|
||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
},
|
||||
|
||||
44
components/inputs/Stepper.tsx
Normal file
44
components/inputs/Stepper.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
import {Text} from "@/components/common/Text";
|
||||
|
||||
interface StepperProps {
|
||||
value: number,
|
||||
step: number,
|
||||
min: number,
|
||||
max: number,
|
||||
onUpdate: (value: number) => void,
|
||||
appendValue?: string,
|
||||
}
|
||||
|
||||
export const Stepper: React.FC<StepperProps> = ({
|
||||
value,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
onUpdate,
|
||||
appendValue
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||
>
|
||||
<Text>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
className={
|
||||
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
|
||||
}
|
||||
>
|
||||
{value}{appendValue}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||
onPress={() => onUpdate(Math.min(max, value + step))}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
@@ -32,6 +32,7 @@ import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import {Stepper} from "@/components/inputs/Stepper";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -483,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View
|
||||
pointerEvents={
|
||||
settings.downloadMethod === "remux" ? "auto" : "none"
|
||||
}
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "remux"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Remux max download</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This is the total media you want to be able to download at the same time.
|
||||
</Text>
|
||||
</View>
|
||||
<Stepper
|
||||
value={settings.remuxConcurrentLimit}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
pointerEvents={
|
||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||
}
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "optimized"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Auto download</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -9,12 +9,34 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native";
|
||||
import {useAtomValue} from "jotai";
|
||||
import {useCallback} from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import {Api} from "@jellyfin/sdk";
|
||||
import {useSettings} from "@/utils/atoms/settings";
|
||||
import {JobStatus} from "@/utils/optimize-server";
|
||||
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||
|
||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||
"-multiple_requests 1", // http
|
||||
"-tcp_nodelay 1", // http
|
||||
// endregion ffmpeg protocol commands
|
||||
|
||||
"-fflags +genpts", // format flags
|
||||
`-i ${url}`, // infile
|
||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||
"-c copy", // streamcopy, preventing transcoding
|
||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
||||
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||
output
|
||||
]
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -25,11 +47,105 @@ import useDownloadHelper from "@/utils/download";
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const {saveImage} = useImageStorage();
|
||||
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
||||
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
}
|
||||
|
||||
const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => {
|
||||
try {
|
||||
let endTime;
|
||||
const returnCode = await session.getReturnCode();
|
||||
const startTime = new Date();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
endTime = new Date();
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await queryClient.invalidateQueries({queryKey: ["downloadedItems"]});
|
||||
|
||||
saveDownloadedItemInfo(item, stat.getSize());
|
||||
writeInfoLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name},
|
||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
|
||||
.replace(/^ +/g, '')
|
||||
)
|
||||
toast.success("Download completed");
|
||||
} else if (returnCode.isValueError()) {
|
||||
endTime = new Date();
|
||||
const allLogs = session.getAllLogsAsString();
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}`
|
||||
.replace(/^ +/g, '')
|
||||
)
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
endTime = new Date();
|
||||
writeInfoLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
|
||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
|
||||
.replace(/^ +/g, '')
|
||||
)
|
||||
}
|
||||
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`
|
||||
.replace(/^ +/g, '')
|
||||
);
|
||||
}
|
||||
}, [processes, setProcesses]);
|
||||
|
||||
const statisticsCallback = useCallback((statistics: Statistics, item: BaseItemDto) => {
|
||||
const videoLength = (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev) => {
|
||||
return prev.map((process) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
id: statistics.getSessionId().toString(),
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
}, [setProcesses, completeCallback]);
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
@@ -37,16 +153,8 @@ export const useRemuxHlsToMp4 = () => {
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(`Download started for ${item.Name}`, {
|
||||
action: {
|
||||
@@ -58,129 +166,34 @@ export const useRemuxHlsToMp4 = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||
);
|
||||
|
||||
try {
|
||||
setProcesses((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: "",
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: "",
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const job: JobStatus = {
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: output,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev) => [...prev, job]);
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev) => {
|
||||
return prev.map((process) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
const startTime = new Date();
|
||||
|
||||
let endTime;
|
||||
if (returnCode.isValueSuccess()) {
|
||||
endTime = new Date();
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
|
||||
item.Name
|
||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
||||
(endTime.getTime() - startTime.getTime()) / 1000
|
||||
}s`
|
||||
);
|
||||
if (!item) throw new Error("Item is undefined");
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await saveDownloadedItemInfo(item, stat.getSize());
|
||||
toast.success("Download completed");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
endTime = new Date();
|
||||
const allLogs = session.getAllLogsAsString();
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
|
||||
item.Name
|
||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
||||
(endTime.getTime() - startTime.getTime()) / 1000
|
||||
}s. All logs: ${allLogs}`
|
||||
);
|
||||
reject(new Error("Remuxing failed"));
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
endTime = new Date();
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
|
||||
item.Name
|
||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
||||
(endTime.getTime() - startTime.getTime()) / 1000
|
||||
}s`
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
session => completeCallback(session, item),
|
||||
undefined,
|
||||
s => statisticsCallback(s, item)
|
||||
)
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`
|
||||
);
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
@@ -188,7 +201,7 @@ export const useRemuxHlsToMp4 = () => {
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[]
|
||||
[settings, processes, setProcesses, completeCallback, statisticsCallback]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import axios from "axios";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {atom, useAtom} from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -56,6 +56,8 @@ export type DownloadedItem = {
|
||||
size: number | undefined;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([])
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
@@ -74,7 +76,7 @@ function useDownloadProvider() {
|
||||
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const authHeader = useMemo(() => {
|
||||
return api?.accessToken;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import {JobStatus} from "@/utils/optimize-server";
|
||||
import {processesAtom} from "@/providers/DownloadProvider";
|
||||
import {useSettings} from "@/utils/atoms/settings";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@@ -49,11 +52,13 @@ export const queueActions = {
|
||||
export const useJobProcessor = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [running, setRunning] = useAtom(runningAtom);
|
||||
const [processes] = useAtom<JobStatus[]>(processesAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length > 0 && !running) {
|
||||
if (queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
|
||||
console.info("Processing queue", queue);
|
||||
queueActions.processJob(queue, setQueue, setRunning);
|
||||
}
|
||||
}, [queue, running, setQueue, setRunning]);
|
||||
}, [processes, queue, running, setQueue, setRunning]);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export type Settings = {
|
||||
downloadMethod: "optimized" | "remux";
|
||||
autoDownload: boolean,
|
||||
showCustomMenuLinks: boolean;
|
||||
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
|
||||
};
|
||||
|
||||
const loadSettings = (): Settings => {
|
||||
@@ -105,6 +106,7 @@ const loadSettings = (): Settings => {
|
||||
downloadMethod: "remux",
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
remuxConcurrentLimit: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||
storage.set("logs", JSON.stringify(recentLogs));
|
||||
};
|
||||
|
||||
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
|
||||
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
|
||||
|
||||
export const readFromLog = (): LogEntry[] => {
|
||||
const logs = storage.getString("logs");
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
|
||||
Reference in New Issue
Block a user