Compare commits

..

7 Commits

Author SHA1 Message Date
Jorrin
8d1ec8f1dc fix DownloadItem show title row styling 2024-04-13 21:56:20 +02:00
Jorrin
e83054c1ca fix missing / on cache directory 2024-04-13 21:25:52 +02:00
Adrian Castro
93111ecdcd fix: a bunch of idiotism 2024-04-13 21:16:03 +02:00
Jorrin
17d907335f Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-13 21:03:25 +02:00
Jorrin
030dca29f9 header padding 2024-04-13 21:03:24 +02:00
Adrian Castro
2b1aa407d4 fix: some nonsense 2024-04-13 20:54:24 +02:00
Jorrin
5b80273dfb Fix for infinite rerender while scraping 🍻 2024-04-13 20:24:59 +02:00
8 changed files with 297 additions and 791 deletions

View File

@@ -41,7 +41,7 @@ export default function Page() {
title: download?.media.title ?? "Downloads", title: download?.media.title ?? "Downloads",
}} }}
/> />
<YStack gap="$3"> <YStack gap="$4">
{download?.downloads.map((download) => { {download?.downloads.map((download) => {
return ( return (
<DownloadItem <DownloadItem

View File

@@ -37,7 +37,7 @@ const formatBytes = (bytes: number, decimals = 2) => {
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}; };
export function DownloadItem(props: DownloadItemProps) { export function DownloadItem(props: DownloadItemProps) {
@@ -100,14 +100,19 @@ export function DownloadItem(props: DownloadItemProps) {
height="100%" height="100%"
/> />
</View> </View>
<YStack gap="$2"> <YStack gap="$2" flex={1}>
<XStack gap="$6" maxWidth="65%"> <XStack justifyContent="space-between" alignItems="center">
<Text fontWeight="$bold" ellipse flexGrow={1}> <Text
fontWeight="$bold"
numberOfLines={1}
ellipsizeMode="tail"
flex={1}
>
{props.item.media.type === "show" && {props.item.media.type === "show" &&
mapSeasonAndEpisodeNumberToText( `${mapSeasonAndEpisodeNumberToText(
props.item.media.season.number, props.item.media.season.number,
props.item.media.episode.number, props.item.media.episode.number,
) + " "} )} `}
{props.item.media.title} {props.item.media.title}
</Text> </Text>
{props.item.type !== "hls" && ( {props.item.type !== "hls" && (

View File

@@ -43,7 +43,7 @@ export default function ScreenLayout({
start={[0, 0]} start={[0, 0]}
end={[1, 1]} end={[1, 1]}
flexGrow={1} flexGrow={1}
paddingTop={showHeader ? insets.top : insets.top + 50} paddingTop={showHeader ? insets.top + 16 : insets.top + 50}
> >
{showHeader && <Header />} {showHeader && <Header />}
<ScrollView <ScrollView

View File

@@ -28,11 +28,9 @@ export const BottomControls = () => {
const { currentTime, remainingTime } = useMemo(() => { const { currentTime, remainingTime } = useMemo(() => {
if (status?.isLoaded) { if (status?.isLoaded) {
const current = mapMillisecondsToTime(status.positionMillis ?? 0); const current = mapMillisecondsToTime(status.positionMillis ?? 0);
const remaining = const remaining = `-${mapMillisecondsToTime(
"-" + (status.durationMillis ?? 0) - (status.positionMillis ?? 0),
mapMillisecondsToTime( )}`;
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
);
return { currentTime: current, remainingTime: remaining }; return { currentTime: current, remainingTime: remaining };
} else { } else {
return { currentTime: "", remainingTime: "" }; return { currentTime: "", remainingTime: "" };

View File

@@ -124,97 +124,103 @@ export const useDownloadManager = () => {
[setDownloads], [setDownloads],
); );
const saveFileToMediaLibraryAndDeleteOriginal = async ( const saveFileToMediaLibraryAndDeleteOriginal = useCallback(
fileUri: string, async (fileUri: string, download: Download): Promise<Asset | void> => {
download: Download, console.log(
): Promise<Asset | void> => { "Saving file to media library and deleting original",
console.log("Saving file to media library and deleting original", fileUri); fileUri,
try { );
updateDownloadItem(download.id, { status: "importing" }); try {
updateDownloadItem(download.id, { status: "importing" });
const asset = await MediaLibrary.createAssetAsync(fileUri); const asset = await MediaLibrary.createAssetAsync(fileUri);
const { localUri } = await MediaLibrary.getAssetInfoAsync(asset); const { localUri } = await MediaLibrary.getAssetInfoAsync(asset);
await FileSystem.deleteAsync(fileUri); await FileSystem.deleteAsync(fileUri);
updateDownloadItem(download.id, { updateDownloadItem(download.id, {
status: "finished", status: "finished",
localPath: localUri, localPath: localUri,
}); });
console.log("File saved to media library and original deleted"); console.log("File saved to media library and original deleted");
showToast("Download finished", { showToast("Download finished", {
burntOptions: { preset: "done" }, burntOptions: { preset: "done" },
}); });
return asset; return asset;
} catch (error) { } catch (error) {
console.error("Error saving file to media library:", error); console.error("Error saving file to media library:", error);
showToast("Download failed", { showToast("Download failed", {
burntOptions: { preset: "error" }, burntOptions: { preset: "error" },
}); });
}
};
const downloadMP4 = async (
url: string,
downloadItem: Download,
headers: Record<string, string>,
): Promise<Asset | void> => {
let lastBytesWritten = 0;
let lastTimestamp = Date.now();
const updateProgress = (downloadProgress: DownloadProgressData) => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000;
if (timeElapsed === 0) return;
const newBytes = downloadProgress.totalBytesWritten - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024 / 1024;
const progress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
updateDownloadItem(downloadItem.id, {
progress,
speed,
fileSize: downloadProgress.totalBytesExpectedToWrite,
downloaded: downloadProgress.totalBytesWritten,
});
lastBytesWritten = downloadProgress.totalBytesWritten;
lastTimestamp = currentTime;
};
const fileUri = `${FileSystem.cacheDirectory}movie-web${url.split("/").pop()}`;
if (
!(await FileSystem.getInfoAsync(`${FileSystem.cacheDirectory}movie-web`))
.exists
) {
console.error("Cache directory is unavailable");
return;
}
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{
headers,
},
updateProgress,
);
try {
const result = await downloadResumable.downloadAsync();
if (result) {
console.log("Finished downloading to ", result.uri);
return saveFileToMediaLibraryAndDeleteOriginal(
result.uri,
downloadItem,
);
} }
} catch (e) { },
console.error(e); [updateDownloadItem, showToast],
} );
};
const downloadMP4 = useCallback(
async (
url: string,
downloadItem: Download,
headers: Record<string, string>,
): Promise<Asset | void> => {
let lastBytesWritten = 0;
let lastTimestamp = Date.now();
const updateProgress = (downloadProgress: DownloadProgressData) => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000;
if (timeElapsed === 0) return;
const newBytes = downloadProgress.totalBytesWritten - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024 / 1024;
const progress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
updateDownloadItem(downloadItem.id, {
progress,
speed,
fileSize: downloadProgress.totalBytesExpectedToWrite,
downloaded: downloadProgress.totalBytesWritten,
});
lastBytesWritten = downloadProgress.totalBytesWritten;
lastTimestamp = currentTime;
};
const fileUri = `${FileSystem.cacheDirectory}movie-web/${url.split("/").pop()}`;
if (
!(
await FileSystem.getInfoAsync(`${FileSystem.cacheDirectory}movie-web`)
).exists
) {
await ensureDirExists(`${FileSystem.cacheDirectory}movie-web`);
}
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{
headers,
},
updateProgress,
);
try {
const result = await downloadResumable.downloadAsync();
if (result) {
console.log("Finished downloading to ", result.uri);
return saveFileToMediaLibraryAndDeleteOriginal(
result.uri,
downloadItem,
);
}
} catch (e) {
console.error(e);
}
},
[updateDownloadItem, saveFileToMediaLibraryAndDeleteOriginal],
);
const cleanupDownload = useCallback( const cleanupDownload = useCallback(
async (segmentDir: string, download: Download) => { async (segmentDir: string, download: Download) => {
@@ -224,162 +230,177 @@ export const useDownloadManager = () => {
[removeDownload], [removeDownload],
); );
const downloadHLS = async ( const downloadHLS = useCallback(
url: string, async (
download: Download, url: string,
headers: Record<string, string>, download: Download,
) => { headers: Record<string, string>,
const segments = await extractSegmentsFromHLS(url, headers); ) => {
const segments = await extractSegmentsFromHLS(url, headers);
if (!segments || segments.length === 0) { if (!segments || segments.length === 0) {
return removeDownload(download); return removeDownload(download);
} }
const totalSegments = segments.length; const totalSegments = segments.length;
let segmentsDownloaded = 0; let segmentsDownloaded = 0;
const segmentDir = `${FileSystem.cacheDirectory}movie-web/segments/`; const segmentDir = `${FileSystem.cacheDirectory}movie-web/segments/`;
await ensureDirExists(segmentDir); await ensureDirExists(segmentDir);
const updateProgress = () => { const updateProgress = () => {
const progress = segmentsDownloaded / totalSegments; const progress = segmentsDownloaded / totalSegments;
updateDownloadItem(download.id, { updateDownloadItem(download.id, {
progress, progress,
downloaded: segmentsDownloaded, downloaded: segmentsDownloaded,
fileSize: totalSegments, fileSize: totalSegments,
}); });
}; };
const localSegmentPaths = []; const localSegmentPaths = [];
for (const [index, segment] of segments.entries()) {
if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download);
return;
}
const segmentFile = `${segmentDir}${index}.ts`;
localSegmentPaths.push(segmentFile);
try {
await downloadSegment(segment, segmentFile, headers);
if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download);
return;
}
segmentsDownloaded++;
updateProgress();
} catch (e) {
console.error(e);
if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download);
return;
}
}
}
for (const [index, segment] of segments.entries()) {
if (getCancellationFlag(download.id)) { if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download); return removeDownload(download);
}
updateDownloadItem(download.id, { status: "merging" });
const uri = await VideoManager.mergeVideos(
localSegmentPaths,
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
);
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
uri,
download,
);
return asset;
},
[
getCancellationFlag,
updateDownloadItem,
saveFileToMediaLibraryAndDeleteOriginal,
removeDownload,
cleanupDownload,
],
);
const startDownload = useCallback(
async (
url: string,
type: "mp4" | "hls",
media: ScrapeMedia,
headers?: Record<string, string>,
): Promise<Asset | void> => {
const { allowMobileData } = useNetworkSettingsStore.getState();
const { type: networkType } = await Network.getNetworkStateAsync();
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
showToast("Mobile data downloads are disabled", {
burntOptions: { preset: "error" },
});
return; return;
} }
const segmentFile = `${segmentDir}${index}.ts`; const { status } = await MediaLibrary.requestPermissionsAsync();
localSegmentPaths.push(segmentFile); if (status !== MediaLibrary.PermissionStatus.GRANTED) {
showToast("Permission denied", {
try { burntOptions: { preset: "error" },
await downloadSegment(segment, segmentFile, headers); });
return;
if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download);
return;
}
segmentsDownloaded++;
updateProgress();
} catch (e) {
console.error(e);
if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download);
return;
}
} }
}
if (getCancellationFlag(download.id)) { const existingDownload = downloads.find(
return removeDownload(download); (d) => d.media.tmdbId === media.tmdbId,
}
updateDownloadItem(download.id, { status: "merging" });
const uri = await VideoManager.mergeVideos(
localSegmentPaths,
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
);
const asset = await saveFileToMediaLibraryAndDeleteOriginal(uri, download);
return asset;
};
const startDownload = async (
url: string,
type: "mp4" | "hls",
media: ScrapeMedia,
headers?: Record<string, string>,
): Promise<Asset | void> => {
const { allowMobileData } = useNetworkSettingsStore.getState();
const { type: networkType } = await Network.getNetworkStateAsync();
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
showToast("Mobile data downloads are disabled", {
burntOptions: { preset: "error" },
});
return;
}
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
showToast("Permission denied", {
burntOptions: { preset: "error" },
});
return;
}
const existingDownload = downloads.find(
(d) => d.media.tmdbId === media.tmdbId,
);
if (existingDownload && media.type === "movie") {
showToast("Download already exists", {
burntOptions: { preset: "error" },
});
return;
}
if (existingDownload && media.type === "show") {
const existingEpisode = existingDownload.downloads.find(
(d) =>
d.media.type === "show" &&
d.media.episode.tmdbId === media.episode.tmdbId,
); );
if (existingEpisode) { if (existingDownload && media.type === "movie") {
showToast("Download already exists", { showToast("Download already exists", {
burntOptions: { preset: "error" }, burntOptions: { preset: "error" },
}); });
return; return;
} }
}
showToast("Download started", {
burntOptions: { preset: "none" },
});
const newDownload: Download = { if (existingDownload && media.type === "show") {
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, const existingEpisode = existingDownload.downloads.find(
progress: 0, (d) =>
speed: 0, d.media.type === "show" &&
fileSize: 0, d.media.episode.tmdbId === media.episode.tmdbId,
downloaded: 0,
type,
url,
status: "downloading",
media,
};
if (existingDownload) {
existingDownload.downloads.push(newDownload);
setDownloads((prev) => {
return prev.map((d) =>
d.media.tmdbId === media.tmdbId ? existingDownload : d,
); );
});
} else {
setDownloads((prev) => {
return [...prev, { media, downloads: [newDownload] }];
});
}
if (type === "mp4") { if (existingEpisode) {
const asset = await downloadMP4(url, newDownload, headers ?? {}); showToast("Download already exists", {
return asset; burntOptions: { preset: "error" },
} else if (type === "hls") { });
const asset = await downloadHLS(url, newDownload, headers ?? {}); return;
return asset; }
} }
}; showToast("Download started", {
burntOptions: { preset: "none" },
});
const newDownload: Download = {
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
progress: 0,
speed: 0,
fileSize: 0,
downloaded: 0,
type,
url,
status: "downloading",
media,
};
if (existingDownload) {
existingDownload.downloads.push(newDownload);
setDownloads((prev) => {
return prev.map((d) =>
d.media.tmdbId === media.tmdbId ? existingDownload : d,
);
});
} else {
setDownloads((prev) => {
return [...prev, { media, downloads: [newDownload] }];
});
}
if (type === "mp4") {
const asset = await downloadMP4(url, newDownload, headers ?? {});
return asset;
} else if (type === "hls") {
const asset = await downloadHLS(url, newDownload, headers ?? {});
return asset;
}
},
[downloads, showToast, setDownloads, downloadMP4, downloadHLS],
);
const downloadSegment = async ( const downloadSegment = async (
segmentUrl: string, segmentUrl: string,

View File

@@ -29,7 +29,7 @@
}, },
"prettier": "@movie-web/prettier-config", "prettier": "@movie-web/prettier-config",
"dependencies": { "dependencies": {
"@movie-web/providers": "^2.2.9", "@movie-web/providers": "^2.3.0",
"parse-hls": "^1.0.7", "parse-hls": "^1.0.7",
"srt-webvtt": "^2.0.0", "srt-webvtt": "^2.0.0",
"tmdb-ts": "^1.6.1" "tmdb-ts": "^1.6.1"

View File

@@ -228,7 +228,7 @@ export async function findHLSQuality(
const chosenQuality = sortedStreams[highest ? 0 : sortedStreams.length - 1]; const chosenQuality = sortedStreams[highest ? 0 : sortedStreams.length - 1];
if (!chosenQuality) return null; if (!chosenQuality) return null;
return chosenQuality.uri; return constructFullUrl(playlistUrl, chosenQuality.uri);
} catch (e) { } catch (e) {
return null; return null;
} }

590
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff