mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-02 07:52:34 +00:00
Merge branch 'develop' into sonarqube
This commit is contained in:
143
.github/workflows/artifact-comment.yml
vendored
Normal file
143
.github/workflows/artifact-comment.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: 📝 Artifact Comment on PR
|
||||
|
||||
concurrency:
|
||||
group: artifact-comment-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "🏗️ Build iOS IPA (phone)"
|
||||
- "🏗️ Build iOS IPA (tv)"
|
||||
- "🏗️ Build Android APK (phone)"
|
||||
- "🏗️ Build Android APK (tv)"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
comment-artifacts:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
console.log('Workflow run event:', JSON.stringify(github.event.workflow_run, null, 2));
|
||||
|
||||
// Find PR associated with this commit
|
||||
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No pull request found for commit: ${{ github.event.workflow_run.head_sha }}');
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = pullRequests[0];
|
||||
const runId = "${{ github.event.workflow_run.id }}";
|
||||
|
||||
console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`);
|
||||
|
||||
// Get artifacts from the workflow run
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
console.log(`Found ${artifacts?.artifacts?.length || 0} artifacts`);
|
||||
|
||||
if (!artifacts || artifacts.artifacts.length === 0) {
|
||||
console.log('No artifacts found for this run');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort and categorize artifacts
|
||||
const androidArtifacts = artifacts.artifacts
|
||||
.filter(a => a.name.includes('android'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const iosArtifacts = artifacts.artifacts
|
||||
.filter(a => a.name.includes('ios'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
console.log(`Android artifacts: ${androidArtifacts.length}, iOS artifacts: ${iosArtifacts.length}`);
|
||||
|
||||
// Build comment body with table format
|
||||
let commentBody = `## 📱 Build Artifacts Ready!\n\n`;
|
||||
commentBody += `✅ **Workflow completed successfully** for PR #${pr.number}\n`;
|
||||
commentBody += `📦 **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`;
|
||||
|
||||
if (androidArtifacts.length === 0 && iosArtifacts.length === 0) {
|
||||
commentBody += `⚠️ No mobile app artifacts found in this build.\n\n`;
|
||||
} else {
|
||||
// Create table for better organization
|
||||
commentBody += `| Platform | Device Type | Download Link |\n`;
|
||||
commentBody += `|----------|-------------|---------------|\n`;
|
||||
|
||||
// Add Android artifacts
|
||||
androidArtifacts.forEach(artifact => {
|
||||
const isTV = artifact.name.includes('tv');
|
||||
const deviceType = isTV ? '📺 Android TV' : '📱 Android Phone';
|
||||
const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`;
|
||||
commentBody += `| 🤖 Android | ${deviceType} | [📥 Download APK](${nightlyLink}) |\n`;
|
||||
});
|
||||
|
||||
// Add iOS artifacts
|
||||
iosArtifacts.forEach(artifact => {
|
||||
const isTV = artifact.name.includes('tv');
|
||||
const deviceType = isTV ? '📺 Apple TV' : '📱 iPhone/iPad';
|
||||
const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`;
|
||||
commentBody += `| 🍎 iOS | ${deviceType} | [📥 Download IPA](${nightlyLink}) |\n`;
|
||||
});
|
||||
|
||||
commentBody += `\n`;
|
||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
|
||||
commentBody += `> ⚠️ **Note**: Artifacts expire in 7 days from build date\n\n`;
|
||||
}
|
||||
|
||||
commentBody += `<sub>*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*</sub>`;
|
||||
commentBody += `\n<!-- streamyfin-artifact-comment -->`;
|
||||
|
||||
// Find existing bot comment to update
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('<!-- streamyfin-artifact-comment -->')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log(`✅ Updated comment ${botComment.id} on PR #${pr.number}`);
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody
|
||||
});
|
||||
console.log(`✅ Created new comment on PR #${pr.number}`);
|
||||
}
|
||||
@@ -468,6 +468,7 @@ const TranscodingStreamView = ({
|
||||
};
|
||||
|
||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Video",
|
||||
@@ -501,7 +502,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
return (
|
||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||
<TranscodingStreamView
|
||||
title='Video'
|
||||
title={t("common.video")}
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
@@ -518,7 +519,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title='Audio'
|
||||
title={t("common.audio")}
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
@@ -536,7 +537,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
|
||||
{subtitleStream && (
|
||||
<TranscodingStreamView
|
||||
title='Subtitle'
|
||||
title={t("common.subtitle")}
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
|
||||
@@ -316,7 +316,69 @@ const Page: React.FC = () => {
|
||||
<View>
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{renderActionButton()}
|
||||
{isLoading || isFetching ? (
|
||||
<Button
|
||||
loading={true}
|
||||
disabled={true}
|
||||
color='purple'
|
||||
className='mt-4'
|
||||
/>
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname:
|
||||
mediaType === MediaType.MOVIE
|
||||
? "/(auth)/(tabs)/(search)/items/page"
|
||||
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||
params:
|
||||
mediaType === MediaType.MOVIE
|
||||
? { id: details?.mediaInfo.jellyfinMediaId }
|
||||
: { id: details?.mediaInfo.jellyfinMediaId },
|
||||
});
|
||||
}}
|
||||
iconLeft={
|
||||
<Ionicons name='play-outline' size={20} color='white' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("common.play")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
</View>
|
||||
|
||||
|
||||
@@ -393,12 +393,17 @@ function Layout() {
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Dimensions, Pressable, View } from "react-native";
|
||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -25,6 +26,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { ItemImage } from "./common/ItemImage";
|
||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { PlayButton } from "./PlayButton";
|
||||
import { PlayedStatus } from "./PlayedStatus";
|
||||
@@ -150,6 +152,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
const router = useRouter();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const translateX = useSharedValue(-currentIndex * screenWidth);
|
||||
|
||||
@@ -301,6 +304,14 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
[hasItems, items, onItemChange, translateX],
|
||||
);
|
||||
|
||||
const navigateToItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(home)");
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
||||
.onUpdate((event) => {
|
||||
@@ -591,7 +602,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
|
||||
{/* Logo Section */}
|
||||
{itemLogoUrl && (
|
||||
<View
|
||||
<TouchableOpacity
|
||||
onPress={() => navigateToItem(item)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: LOGO_BOTTOM_POSITION,
|
||||
@@ -611,7 +623,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Type and Genres Section */}
|
||||
@@ -625,41 +637,56 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Animated.Text
|
||||
style={{
|
||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
||||
fontSize: GENRES_FONT_SIZE,
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
textShadowColor: TEXT_SHADOW_COLOR,
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const typeLabel =
|
||||
item.Type === "Series"
|
||||
? "TV Show"
|
||||
: item.Type === "Movie"
|
||||
? "Movie"
|
||||
: item.Type || "";
|
||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||
<Animated.Text
|
||||
style={{
|
||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
||||
fontSize: GENRES_FONT_SIZE,
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
textShadowColor: TEXT_SHADOW_COLOR,
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
let typeLabel = "";
|
||||
|
||||
const genres =
|
||||
item.Genres && item.Genres.length > 0
|
||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
||||
: "";
|
||||
if (item.Type === "Episode") {
|
||||
// For episodes, show season and episode number
|
||||
const season = item.ParentIndexNumber;
|
||||
const episode = item.IndexNumber;
|
||||
if (season && episode) {
|
||||
typeLabel = `S${season} • E${episode}`;
|
||||
} else {
|
||||
typeLabel = "Episode";
|
||||
}
|
||||
} else {
|
||||
typeLabel =
|
||||
item.Type === "Series"
|
||||
? "TV Show"
|
||||
: item.Type === "Movie"
|
||||
? "Movie"
|
||||
: item.Type || "";
|
||||
}
|
||||
|
||||
if (typeLabel && genres) {
|
||||
return `${typeLabel} • ${genres}`;
|
||||
} else if (typeLabel) {
|
||||
return typeLabel;
|
||||
} else if (genres) {
|
||||
return genres;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</Animated.Text>
|
||||
const genres =
|
||||
item.Genres && item.Genres.length > 0
|
||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
||||
: "";
|
||||
|
||||
if (typeLabel && genres) {
|
||||
return `${typeLabel} • ${genres}`;
|
||||
} else if (typeLabel) {
|
||||
return typeLabel;
|
||||
} else if (genres) {
|
||||
return genres;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Controls Section */}
|
||||
|
||||
@@ -225,7 +225,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
if (!mediaSource) {
|
||||
console.error(`Could not get download URL for ${item.Name}`);
|
||||
toast.error(
|
||||
t("Could not get download URL for {{itemName}}", {
|
||||
t("home.downloads.toasts.could_not_get_download_url_for_item", {
|
||||
itemName: item.Name,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
@@ -109,9 +110,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action buttons in top right corner */}
|
||||
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
||||
{process.status === "downloading" && (
|
||||
{/* Action buttons in bottom right corner */}
|
||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
||||
{process.status === "downloading" && Platform.OS !== "ios" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => handlePause(process.id)}
|
||||
className='p-1'
|
||||
@@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
<Ionicons name='pause' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{process.status === "paused" && (
|
||||
{process.status === "paused" && Platform.OS !== "ios" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => handleResume(process.id)}
|
||||
className='p-1'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
@@ -16,6 +17,7 @@ interface Props extends ViewProps {
|
||||
}
|
||||
|
||||
export const ItemActions = ({ item, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const trailerLink = useMemo(() => {
|
||||
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
||||
return item.RemoteTrailers[0].Url;
|
||||
@@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
|
||||
|
||||
const openTrailer = useCallback(async () => {
|
||||
if (!trailerLink) {
|
||||
Alert.alert("No trailer available");
|
||||
Alert.alert(t("common.no_trailer_available"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
|
||||
} catch (err) {
|
||||
console.error("Failed to open trailer link:", err);
|
||||
}
|
||||
}, [trailerLink]);
|
||||
}, [trailerLink, t]);
|
||||
|
||||
return (
|
||||
<View className='' {...props}>
|
||||
|
||||
@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
|
||||
|
||||
if (settings?.autoDownload === true && !registered) {
|
||||
registerBackgroundFetchAsync();
|
||||
toast.success("Background downloads enabled");
|
||||
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
||||
} else if (settings?.autoDownload === false && registered) {
|
||||
unregisterBackgroundFetchAsync();
|
||||
toast.info("Background downloads disabled");
|
||||
toast.info(t("home.settings.toasts.background_downloads_disabled"));
|
||||
} else if (settings?.autoDownload === true && registered) {
|
||||
// Don't to anything
|
||||
} else if (settings?.autoDownload === false && !registered) {
|
||||
|
||||
@@ -50,9 +50,11 @@ function withRNBackgroundDownloader(config) {
|
||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||
// Setting the property once at the project level is sufficient.
|
||||
["Debug", "Release"].forEach((cfg) => {
|
||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||
project.updateBuildProperty(
|
||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||
"Streamyfin/Streamyfin-Bridging-Header.h",
|
||||
bridgingHeaderPath,
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { fetchAndParseSegments } from "@/utils/segments";
|
||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||
@@ -43,37 +43,60 @@ const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
// Cap progress at 99% to avoid showing 100% before the download is actually complete
|
||||
const MAX_PROGRESS_BEFORE_COMPLETION = 99;
|
||||
|
||||
// Estimate the total download size in bytes for a job. If the media source
|
||||
// provides a Size, use that. Otherwise, if we have a bitrate and run time
|
||||
// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
|
||||
const calculateEstimatedSize = (p: JobStatus): number => {
|
||||
let size = p.mediaSource.Size;
|
||||
const maxBitrate = p.maxBitrate.value;
|
||||
if (
|
||||
maxBitrate &&
|
||||
size &&
|
||||
p.mediaSource.Bitrate &&
|
||||
maxBitrate < p.mediaSource.Bitrate
|
||||
) {
|
||||
size = (size / p.mediaSource.Bitrate) * maxBitrate;
|
||||
}
|
||||
// This function is for estimated size, so just return the adjusted size
|
||||
return size ?? 0;
|
||||
};
|
||||
const size = p.mediaSource?.Size || 0;
|
||||
const maxBitrate = p.maxBitrate?.value;
|
||||
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
|
||||
|
||||
// Helper to calculate download speed
|
||||
const calculateSpeed = (
|
||||
process: JobStatus,
|
||||
newBytesDownloaded: number,
|
||||
): number | undefined => {
|
||||
const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
|
||||
const deltaBytes = newBytesDownloaded - oldBytes;
|
||||
|
||||
if (lastProgressUpdateTime && deltaBytes > 0) {
|
||||
const deltaTimeInSeconds =
|
||||
(Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
|
||||
if (deltaTimeInSeconds > 0) {
|
||||
return deltaBytes / deltaTimeInSeconds;
|
||||
if (!size && maxBitrate && runTimeTicks > 0) {
|
||||
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second
|
||||
const seconds = runTimeTicks / 10000000;
|
||||
if (seconds > 0) {
|
||||
// maxBitrate is in bits per second; convert to bytes
|
||||
return Math.round((maxBitrate / 8) * seconds);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
return size || 0;
|
||||
};
|
||||
|
||||
// Calculate download speed in bytes/sec based on a job's last update time
|
||||
// and previously recorded bytesDownloaded.
|
||||
const calculateSpeed = (
|
||||
p: JobStatus,
|
||||
currentBytesDownloaded?: number,
|
||||
): number | undefined => {
|
||||
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
|
||||
const now = Date.now();
|
||||
|
||||
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
|
||||
const last = new Date(p.lastSessionUpdateTime).getTime();
|
||||
const deltaTime = (now - last) / 1000;
|
||||
if (deltaTime > 0) {
|
||||
const current =
|
||||
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
|
||||
const deltaBytes = current - p.lastSessionBytes;
|
||||
if (deltaBytes > 0) return deltaBytes / deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to total-based deltas for compatibility
|
||||
if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
|
||||
return undefined;
|
||||
const last = new Date(p.lastProgressUpdateTime).getTime();
|
||||
const deltaTime = (now - last) / 1000;
|
||||
if (deltaTime <= 0) return undefined;
|
||||
const prev = p.bytesDownloaded || 0;
|
||||
const current = currentBytesDownloaded ?? prev;
|
||||
const deltaBytes = current - prev;
|
||||
if (deltaBytes <= 0) return undefined;
|
||||
return deltaBytes / deltaTime;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
@@ -178,28 +201,97 @@ function useDownloadProvider() {
|
||||
});
|
||||
|
||||
const currentProcesses = [...processes, ...missingProcesses];
|
||||
const updatedProcesses = currentProcesses.map((process) => {
|
||||
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
|
||||
// We make an wild guess by comparing bitrates
|
||||
const task = findTaskById(process.id);
|
||||
if (task && process.status === "downloading") {
|
||||
const estimatedSize = calculateEstimatedSize(process);
|
||||
let progress = process.progress;
|
||||
if (estimatedSize > 0) {
|
||||
progress = (100 / estimatedSize) * task.bytesDownloaded;
|
||||
const updatedProcesses = currentProcesses.map((p) => {
|
||||
// Enhanced filtering to prevent iOS zombie task interference
|
||||
// Only update progress for downloads that are actively downloading
|
||||
if (p.status !== "downloading") {
|
||||
return p;
|
||||
}
|
||||
|
||||
// Find task for this process
|
||||
const task = tasks.find((s: any) => s.id === p.id);
|
||||
if (!task) {
|
||||
return p; // No task found, keep current state
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS zombie task detection
|
||||
// iOS: Extra validation to prevent zombie task interference
|
||||
if (Platform.OS === "ios") {
|
||||
// Check if we have multiple tasks for same ID (zombie detection)
|
||||
const tasksForId = tasks.filter((t: any) => t.id === p.id);
|
||||
if (tasksForId.length > 1) {
|
||||
console.warn(
|
||||
`[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
|
||||
);
|
||||
return p; // Don't update progress from potentially conflicting tasks
|
||||
}
|
||||
if (progress >= 100) {
|
||||
progress = 99;
|
||||
|
||||
// If task state looks suspicious (e.g., iOS task stuck in background), be conservative
|
||||
if (
|
||||
task.state &&
|
||||
["SUSPENDED", "PAUSED"].includes(task.state) &&
|
||||
p.status === "downloading"
|
||||
) {
|
||||
console.warn(
|
||||
`[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
|
||||
);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if (task && p.status === "downloading") {
|
||||
const estimatedSize = calculateEstimatedSize(p);
|
||||
let progress = p.progress;
|
||||
|
||||
// If we have a pausedProgress snapshot then merge current session
|
||||
// progress into it. We accept pausedProgress === 0 as valid because
|
||||
// users can pause immediately after starting.
|
||||
if (p.pausedProgress !== undefined) {
|
||||
const totalBytesDownloaded =
|
||||
(p.pausedBytes ?? 0) + task.bytesDownloaded;
|
||||
|
||||
// Calculate progress based on total bytes downloaded vs estimated size
|
||||
progress =
|
||||
estimatedSize > 0
|
||||
? (totalBytesDownloaded / estimatedSize) * 100
|
||||
: 0;
|
||||
|
||||
// Use the total accounted bytes when computing speed so the
|
||||
// displayed speed and progress remain consistent after resume.
|
||||
const speed = calculateSpeed(p, totalBytesDownloaded);
|
||||
|
||||
return {
|
||||
...p,
|
||||
progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
|
||||
speed,
|
||||
bytesDownloaded: totalBytesDownloaded,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: estimatedSize,
|
||||
// Set session bytes to total bytes downloaded
|
||||
lastSessionBytes: totalBytesDownloaded,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
};
|
||||
} else {
|
||||
if (estimatedSize > 0) {
|
||||
progress = (100 / estimatedSize) * task.bytesDownloaded;
|
||||
}
|
||||
if (progress >= 100) {
|
||||
progress = MAX_PROGRESS_BEFORE_COMPLETION;
|
||||
}
|
||||
const speed = calculateSpeed(p, task.bytesDownloaded);
|
||||
return {
|
||||
...p,
|
||||
progress,
|
||||
speed,
|
||||
bytesDownloaded: task.bytesDownloaded,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: estimatedSize,
|
||||
lastSessionBytes: task.bytesDownloaded,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
};
|
||||
}
|
||||
const speed = calculateSpeed(process, task.bytesDownloaded);
|
||||
return {
|
||||
...process,
|
||||
progress,
|
||||
speed,
|
||||
bytesDownloaded: task.bytesDownloaded,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: estimatedSize,
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
@@ -381,10 +473,76 @@ function useDownloadProvider() {
|
||||
async (process: JobStatus) => {
|
||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||
|
||||
// Enhanced cleanup for existing tasks to prevent duplicates
|
||||
try {
|
||||
const allTasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
|
||||
|
||||
if (existingTasks && existingTasks.length > 0) {
|
||||
console.log(
|
||||
`[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < existingTasks.length; i++) {
|
||||
const existingTask = existingTasks[i];
|
||||
console.log(
|
||||
`[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS-specific cleanup
|
||||
// iOS: More aggressive cleanup sequence
|
||||
if (Platform.OS === "ios") {
|
||||
try {
|
||||
await existingTask.pause();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} catch (_pauseErr) {
|
||||
// Ignore pause errors
|
||||
}
|
||||
|
||||
await existingTask.stop();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Multiple complete handler calls to ensure cleanup
|
||||
BackGroundDownloader.completeHandler(process.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
} else {
|
||||
*/
|
||||
|
||||
// Simple cleanup for all platforms (currently Android only)
|
||||
await existingTask.stop();
|
||||
BackGroundDownloader.completeHandler(process.id);
|
||||
|
||||
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
|
||||
|
||||
console.log(
|
||||
`[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
|
||||
);
|
||||
} catch (taskError) {
|
||||
console.warn(
|
||||
`[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
|
||||
taskError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup delay (simplified for Android)
|
||||
const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
|
||||
await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
|
||||
console.log(`[START] Cleanup completed for ${process.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[START] Failed to check/cleanup existing tasks for ${process.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
updateProcess(process.id, {
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
progress: process.progress || 0, // Preserve existing progress for resume
|
||||
});
|
||||
|
||||
BackGroundDownloader?.setConfig({
|
||||
@@ -405,21 +563,42 @@ function useDownloadProvider() {
|
||||
.begin(() => {
|
||||
updateProcess(process.id, {
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: process.progress || 0,
|
||||
bytesDownloaded: process.bytesDownloaded || 0,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
lastSessionBytes: process.lastSessionBytes || 0,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
});
|
||||
})
|
||||
.progress(
|
||||
throttle((data) => {
|
||||
updateProcess(process.id, (currentProcess) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
// If this is a resumed download, add the paused bytes to current session bytes
|
||||
const resumedBytes = currentProcess.pausedBytes || 0;
|
||||
const totalBytes = data.bytesDownloaded + resumedBytes;
|
||||
|
||||
// Calculate progress based on total bytes if we have resumed bytes
|
||||
let percent: number;
|
||||
if (resumedBytes > 0 && data.bytesTotal > 0) {
|
||||
// For resumed downloads, calculate based on estimated total size
|
||||
const estimatedTotal =
|
||||
currentProcess.estimatedTotalSizeBytes ||
|
||||
data.bytesTotal + resumedBytes;
|
||||
percent = (totalBytes / estimatedTotal) * 100;
|
||||
} else {
|
||||
// For fresh downloads, use normal calculation
|
||||
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
}
|
||||
|
||||
return {
|
||||
speed: calculateSpeed(currentProcess, data.bytesDownloaded),
|
||||
speed: calculateSpeed(currentProcess, totalBytes),
|
||||
status: "downloading",
|
||||
progress: percent,
|
||||
bytesDownloaded: data.bytesDownloaded,
|
||||
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
|
||||
bytesDownloaded: totalBytes,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
// update session-only counters - use current session bytes only for speed calc
|
||||
lastSessionBytes: data.bytesDownloaded,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
};
|
||||
});
|
||||
}, 500),
|
||||
@@ -551,7 +730,17 @@ function useDownloadProvider() {
|
||||
if (activeDownloads < concurrentLimit) {
|
||||
const queuedDownload = processes.find((p) => p.status === "queued");
|
||||
if (queuedDownload) {
|
||||
startDownload(queuedDownload);
|
||||
// Reserve the slot immediately to avoid race where startDownload's
|
||||
// asynchronous begin callback hasn't executed yet and multiple
|
||||
// downloads are started, bypassing the concurrent limit.
|
||||
updateProcess(queuedDownload.id, { status: "downloading" });
|
||||
startDownload(queuedDownload).catch((error) => {
|
||||
console.error("Failed to start download:", error);
|
||||
updateProcess(queuedDownload.id, { status: "error" });
|
||||
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
|
||||
description: error.message || "Unknown error",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
|
||||
@@ -560,8 +749,38 @@ function useDownloadProvider() {
|
||||
async (id: string) => {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
const task = tasks?.find((t: any) => t.id === id);
|
||||
task?.stop();
|
||||
BackGroundDownloader.completeHandler(id);
|
||||
if (task) {
|
||||
// On iOS, suspended tasks need to be cancelled properly
|
||||
if (Platform.OS === "ios") {
|
||||
const state = task.state || task.state?.();
|
||||
if (
|
||||
state === "PAUSED" ||
|
||||
state === "paused" ||
|
||||
state === "SUSPENDED" ||
|
||||
state === "suspended"
|
||||
) {
|
||||
// For suspended tasks, we need to resume first, then stop
|
||||
try {
|
||||
await task.resume();
|
||||
// Small delay to allow resume to take effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch (_resumeError) {
|
||||
// Resume might fail, continue with stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
task.stop();
|
||||
} catch (_err) {
|
||||
// ignore stop errors
|
||||
}
|
||||
try {
|
||||
BackGroundDownloader.completeHandler(id);
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
||||
manageDownloadQueue();
|
||||
},
|
||||
@@ -583,9 +802,8 @@ function useDownloadProvider() {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to clean cache directory:", error);
|
||||
toast.error(t("Failed to clean cache directory."));
|
||||
} catch (_error) {
|
||||
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -621,9 +839,13 @@ function useDownloadProvider() {
|
||||
status: "queued",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setProcesses((prev) => [...prev, job]);
|
||||
setProcesses((prev) => {
|
||||
// Remove any existing processes for this item to prevent duplicates
|
||||
const filtered = prev.filter((p) => p.id !== item.Id);
|
||||
return [...filtered, job];
|
||||
});
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_stated_for_item", {
|
||||
t("home.downloads.toasts.download_started_for_item", {
|
||||
item: item.Name,
|
||||
}),
|
||||
{
|
||||
@@ -833,12 +1055,99 @@ function useDownloadProvider() {
|
||||
const process = processes.find((p) => p.id === id);
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
// TODO: iOS pause functionality temporarily disabled due to background task issues
|
||||
// Remove this check to re-enable iOS pause functionality in the future
|
||||
if (Platform.OS === "ios") {
|
||||
console.warn(
|
||||
`[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
|
||||
);
|
||||
throw new Error("Pause functionality is currently disabled on iOS");
|
||||
}
|
||||
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
const task = tasks?.find((t: any) => t.id === id);
|
||||
if (!task) throw new Error("No task found");
|
||||
|
||||
task.pause();
|
||||
updateProcess(id, { status: "paused" });
|
||||
// Get current progress before stopping
|
||||
const currentProgress = process.progress;
|
||||
const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
|
||||
|
||||
console.log(
|
||||
`[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS pause functionality
|
||||
// iOS-specific aggressive cleanup approach based on GitHub issue #26
|
||||
if (Platform.OS === "ios") {
|
||||
// Get ALL tasks for this ID - there might be multiple zombie tasks
|
||||
const allTasks =
|
||||
await BackGroundDownloader.checkForExistingDownloads();
|
||||
const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
|
||||
|
||||
console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
|
||||
|
||||
// Stop ALL tasks for this ID to prevent zombie processes
|
||||
for (let i = 0; i < tasksForId.length; i++) {
|
||||
const taskToStop = tasksForId[i];
|
||||
console.log(
|
||||
`[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// iOS: pause → stop sequence with delays (based on issue research)
|
||||
await taskToStop.pause();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await taskToStop.stop();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
console.log(
|
||||
`[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
|
||||
);
|
||||
} catch (taskError) {
|
||||
console.warn(
|
||||
`[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
|
||||
taskError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra cleanup delay for iOS NSURLSession to fully stop
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} else {
|
||||
*/
|
||||
|
||||
// Android: simpler approach (currently the only active platform)
|
||||
await task.stop();
|
||||
|
||||
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
|
||||
|
||||
// Clean up the native task handler
|
||||
try {
|
||||
BackGroundDownloader.completeHandler(id);
|
||||
} catch (_err) {
|
||||
console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
|
||||
}
|
||||
|
||||
// Update process state to paused
|
||||
updateProcess(id, {
|
||||
status: "paused",
|
||||
progress: currentProgress,
|
||||
bytesDownloaded: currentBytes,
|
||||
pausedAt: new Date(),
|
||||
pausedProgress: currentProgress,
|
||||
pausedBytes: currentBytes,
|
||||
lastSessionBytes: process.lastSessionBytes ?? currentBytes,
|
||||
lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
|
||||
});
|
||||
|
||||
console.log(`Download paused successfully: ${id}`);
|
||||
} catch (error) {
|
||||
console.error("Error pausing task:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[processes, updateProcess],
|
||||
);
|
||||
@@ -848,38 +1157,79 @@ function useDownloadProvider() {
|
||||
const process = processes.find((p) => p.id === id);
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
const task = tasks?.find((t: any) => t.id === id);
|
||||
if (!task) throw new Error("No task found");
|
||||
|
||||
// Check if task state allows resuming
|
||||
if (task.state === "FAILED") {
|
||||
// TODO: iOS resume functionality temporarily disabled due to background task issues
|
||||
// Remove this check to re-enable iOS resume functionality in the future
|
||||
if (Platform.OS === "ios") {
|
||||
console.warn(
|
||||
"Download task failed, cannot resume. Restarting download.",
|
||||
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
|
||||
);
|
||||
// For failed tasks, we need to restart rather than resume
|
||||
await startDownload(process);
|
||||
return;
|
||||
throw new Error("Resume functionality is currently disabled on iOS");
|
||||
}
|
||||
|
||||
try {
|
||||
task.resume();
|
||||
updateProcess(id, { status: "downloading" });
|
||||
} catch (error: any) {
|
||||
// Handle specific ERROR_CANNOT_RESUME error
|
||||
if (
|
||||
error?.error === "ERROR_CANNOT_RESUME" ||
|
||||
error?.errorCode === 1008
|
||||
) {
|
||||
console.warn("Cannot resume download, attempting to restart instead");
|
||||
await startDownload(process);
|
||||
return; // Return early to prevent error from bubbling up
|
||||
} else {
|
||||
// Only log error for non-handled cases
|
||||
console.error("Error resuming download:", error);
|
||||
throw error; // Re-throw other errors
|
||||
console.log(
|
||||
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
|
||||
);
|
||||
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS resume functionality
|
||||
// Enhanced cleanup for iOS based on GitHub issue research
|
||||
if (Platform.OS === "ios") {
|
||||
try {
|
||||
// Clean up any lingering zombie tasks first (critical for iOS)
|
||||
const allTasks =
|
||||
await BackGroundDownloader.checkForExistingDownloads();
|
||||
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
|
||||
|
||||
if (existingTasks.length > 0) {
|
||||
console.log(
|
||||
`[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
|
||||
);
|
||||
|
||||
for (const task of existingTasks) {
|
||||
try {
|
||||
await task.stop();
|
||||
BackGroundDownloader.completeHandler(id);
|
||||
} catch (cleanupError) {
|
||||
console.warn(`[RESUME] Cleanup error:`, cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for iOS cleanup to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Simple approach: always restart the download from where we left off
|
||||
// This works consistently across all platforms (currently Android only)
|
||||
if (
|
||||
process.pausedProgress !== undefined &&
|
||||
process.pausedBytes !== undefined
|
||||
) {
|
||||
// We have saved pause state - restore it and restart
|
||||
updateProcess(id, {
|
||||
progress: process.pausedProgress,
|
||||
bytesDownloaded: process.pausedBytes,
|
||||
status: "downloading",
|
||||
// Reset session counters for proper speed calculation
|
||||
lastSessionBytes: process.pausedBytes,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
});
|
||||
|
||||
// Small delay to ensure any cleanup in startDownload completes
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const updatedProcess = processes.find((p) => p.id === id);
|
||||
await startDownload(updatedProcess || process);
|
||||
|
||||
console.log(`Download resumed successfully: ${id}`);
|
||||
} else {
|
||||
// No pause state - start from beginning
|
||||
await startDownload(process);
|
||||
}
|
||||
},
|
||||
[processes, updateProcess, startDownload],
|
||||
);
|
||||
@@ -903,6 +1253,21 @@ function useDownloadProvider() {
|
||||
cleanCacheDirectory,
|
||||
updateDownloadedItem,
|
||||
appSizeUsage,
|
||||
dumpDownloadDiagnostics: async (id?: string) => {
|
||||
// Collect JS-side processes and native task info (best-effort)
|
||||
const tasks = BackGroundDownloader
|
||||
? await BackGroundDownloader.checkForExistingDownloads()
|
||||
: [];
|
||||
const extra: any = {
|
||||
processes,
|
||||
nativeTasks: tasks || [],
|
||||
};
|
||||
if (id) {
|
||||
const p = processes.find((x) => x.id === id);
|
||||
extra.focusedProcess = p || null;
|
||||
}
|
||||
return dumpDownloadDiagnostics(extra);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -129,4 +129,14 @@ export type JobStatus = {
|
||||
/** Estimated total size of the download in bytes (optional) this is used when we
|
||||
* download transcoded content because we don't know the size of the file until it's downloaded */
|
||||
estimatedTotalSizeBytes?: number;
|
||||
/** Timestamp when the download was paused (optional) */
|
||||
pausedAt?: Date;
|
||||
/** Progress percentage when download was paused (optional) */
|
||||
pausedProgress?: number;
|
||||
/** Bytes downloaded when download was paused (optional) */
|
||||
pausedBytes?: number;
|
||||
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
|
||||
lastSessionBytes?: number;
|
||||
/** Timestamp when the session-only bytes were last updated. */
|
||||
lastSessionUpdateTime?: Date;
|
||||
};
|
||||
|
||||
@@ -223,7 +223,9 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error Deleting Files"
|
||||
"error_deleting_files": "Error Deleting Files",
|
||||
"background_downloads_enabled": "Background downloads enabled",
|
||||
"background_downloads_disabled": "Background downloads disabled"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
@@ -266,11 +268,23 @@
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Download Completed for {{item}}",
|
||||
"download_started_for_item": "Download Started for {{item}}",
|
||||
"failed_to_start_download": "Failed to start download",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Subtitle",
|
||||
"play": "Play"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
"x_items": "{{count}} Items",
|
||||
|
||||
@@ -77,6 +77,17 @@ export const clearLogs = () => {
|
||||
storage.delete("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
const diagnostics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
processes: extra?.processes || [],
|
||||
nativeTasks: extra?.nativeTasks || [],
|
||||
focusedProcess: extra?.focusedProcess || null,
|
||||
};
|
||||
writeDebugLog("Download diagnostics", diagnostics);
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
export function useLog() {
|
||||
const context = useContext(LogContext);
|
||||
if (context === null) {
|
||||
|
||||
Reference in New Issue
Block a user