forked from Ninjalama/streamyfin_mirror
Compare commits
2 Commits
master
...
feat/remov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6c7a7603 | ||
|
|
5a07eccd9b |
@@ -231,7 +231,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (usingOptimizedServer) {
|
if (usingOptimizedServer) {
|
||||||
await startBackgroundDownload(url, item, source);
|
await startBackgroundDownload(url, item, source);
|
||||||
} else {
|
} else {
|
||||||
await startRemuxing(item, url, source);
|
await startRemuxing(item, url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
|
|||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import {
|
||||||
|
brightness,
|
||||||
|
ColorMatrix,
|
||||||
|
colorTone,
|
||||||
|
concatColorMatrices,
|
||||||
|
contrast,
|
||||||
|
saturate,
|
||||||
|
sepia,
|
||||||
|
tint,
|
||||||
|
} from "react-native-color-matrix-image-filters";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(false);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
logo={
|
logo={
|
||||||
<>
|
<>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<Image
|
<ColorMatrix
|
||||||
source={{
|
matrix={[
|
||||||
uri: logoUrl,
|
1,
|
||||||
}}
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Red channel remains unchanged
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Green channel remains unchanged
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0, // Blue channel remains unchanged
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
-1, // Make black (R=0, G=0, B=0) transparent
|
||||||
|
]}
|
||||||
style={{
|
style={{
|
||||||
height: 130,
|
height: 130,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
}}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
>
|
||||||
onError={() => setLoadingLogo(false)}
|
<Image
|
||||||
/>
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ColorMatrix>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log";
|
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -9,34 +9,34 @@ import {
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native";
|
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||||
import {useAtomValue} from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {useCallback} from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import useImageStorage from "./useImageStorage";
|
import useImageStorage from "./useImageStorage";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
import {Api} from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {JobStatus} from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
|
||||||
const createFFmpegCommand = (url: string, output: string) => [
|
const createFFmpegCommand = (url: string, output: string) => [
|
||||||
"-y", // overwrite output files without asking
|
"-y", // overwrite output files without asking
|
||||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||||
|
|
||||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||||
"-multiple_requests 1", // http
|
"-multiple_requests 1", // http
|
||||||
"-tcp_nodelay 1", // http
|
"-tcp_nodelay 1", // http
|
||||||
// endregion ffmpeg protocol commands
|
// endregion ffmpeg protocol commands
|
||||||
|
|
||||||
"-fflags +genpts", // format flags
|
"-fflags +genpts", // format flags
|
||||||
`-i ${url}`, // infile
|
`-i ${url}`, // infile
|
||||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||||
"-c copy", // streamcopy, preventing transcoding
|
"-c copy", // streamcopy, preventing transcoding
|
||||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
"-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
|
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||||
output
|
output,
|
||||||
]
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -51,9 +51,9 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const {saveImage} = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
|
const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
await saveSeriesPrimaryImage(item);
|
await saveSeriesPrimaryImage(item);
|
||||||
@@ -66,89 +66,100 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await saveImage(item.Id, itemImage?.uri);
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
}
|
};
|
||||||
|
|
||||||
const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => {
|
const completeCallback = useCallback(
|
||||||
try {
|
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||||
let endTime;
|
try {
|
||||||
const returnCode = await session.getReturnCode();
|
let endTime;
|
||||||
const startTime = new Date();
|
const returnCode = await session.getReturnCode();
|
||||||
|
const startTime = new Date();
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
endTime = new Date();
|
endTime = new Date();
|
||||||
const stat = await session.getLastReceivedStatistics();
|
const stat = await session.getLastReceivedStatistics();
|
||||||
await queryClient.invalidateQueries({queryKey: ["downloadedItems"]});
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["downloadedItems"],
|
||||||
|
});
|
||||||
|
|
||||||
saveDownloadedItemInfo(item, stat.getSize());
|
saveDownloadedItemInfo(item, stat.getSize());
|
||||||
writeInfoLog(
|
writeInfoLog(
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name},
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
|
||||||
|
item.Name
|
||||||
|
},
|
||||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
|
duration: ${
|
||||||
.replace(/^ +/g, '')
|
(endTime.getTime() - startTime.getTime()) / 1000
|
||||||
)
|
}s`.replace(/^ +/g, "")
|
||||||
toast.success("Download completed");
|
);
|
||||||
} else if (returnCode.isValueError()) {
|
toast.success("Download completed");
|
||||||
endTime = new Date();
|
} else if (returnCode.isValueError()) {
|
||||||
const allLogs = session.getAllLogsAsString();
|
endTime = new Date();
|
||||||
writeErrorLog(
|
const allLogs = session.getAllLogsAsString();
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
writeErrorLog(
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}`
|
duration: ${
|
||||||
.replace(/^ +/g, '')
|
(endTime.getTime() - startTime.getTime()) / 1000
|
||||||
)
|
}s. All logs: ${allLogs}`.replace(/^ +/g, "")
|
||||||
} else if (returnCode.isValueCancel()) {
|
);
|
||||||
endTime = new Date();
|
} else if (returnCode.isValueCancel()) {
|
||||||
writeInfoLog(
|
endTime = new Date();
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
|
writeInfoLog(
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
|
||||||
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
|
duration: ${
|
||||||
.replace(/^ +/g, '')
|
(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((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]);
|
[setProcesses, completeCallback]
|
||||||
|
);
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string) => {
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
@@ -177,17 +188,17 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}
|
};
|
||||||
|
|
||||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||||
setProcesses((prev) => [...prev, job]);
|
setProcesses((prev) => [...prev, job]);
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(
|
await FFmpegKit.executeAsync(
|
||||||
createFFmpegCommand(url, output).join(" "),
|
createFFmpegCommand(url, output).join(" "),
|
||||||
session => completeCallback(session, item),
|
(session) => completeCallback(session, item),
|
||||||
undefined,
|
undefined,
|
||||||
s => statisticsCallback(s, item)
|
(s) => statisticsCallback(s, item)
|
||||||
)
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
console.error("Failed to remux:", error);
|
console.error("Failed to remux:", error);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-awesome-slider": "^2.5.6",
|
||||||
"react-native-bottom-tabs": "^0.7.3",
|
"react-native-bottom-tabs": "^0.7.3",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
|
"react-native-color-matrix-image-filters": "^7.0.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.9.0",
|
||||||
"react-native-device-info": "^14.0.1",
|
"react-native-device-info": "^14.0.1",
|
||||||
"react-native-edge-to-edge": "^1.1.1",
|
"react-native-edge-to-edge": "^1.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user