diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml
new file mode 100644
index 00000000..67e31268
--- /dev/null
+++ b/.github/workflows/artifact-comment.yml
@@ -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 += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`;
+ commentBody += `\n`;
+
+ // 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('')
+ );
+
+ 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}`);
+ }
diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx
index 9038e2fd..cd5e32a0 100644
--- a/app/(auth)/(tabs)/(home)/sessions/index.tsx
+++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx
@@ -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 (
{
/>
{
{subtitleStream && (
{
g.name) || []} />
- {renderActionButton()}
+ {isLoading || isFetching ? (
+
+ ) : canRequest ? (
+
+ ) : (
+ details?.mediaInfo?.jellyfinMediaId && (
+
+ {!Platform.isTV && (
+
+ )}
+
+
+ )
+ )}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 510a80ba..b44a2b3c 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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();
};
diff --git a/components/AppleTVCarousel.tsx b/components/AppleTVCarousel.tsx
index 2f5b2d50..c439069d 100644
--- a/components/AppleTVCarousel.tsx
+++ b/components/AppleTVCarousel.tsx
@@ -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 = ({
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 = ({
[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 = ({
{/* Logo Section */}
{itemLogoUrl && (
- navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
@@ -611,7 +623,7 @@ export const AppleTVCarousel: React.FC = ({
}}
contentFit='contain'
/>
-
+
)}
{/* Type and Genres Section */}
@@ -625,41 +637,56 @@ export const AppleTVCarousel: React.FC = ({
alignItems: "center",
}}
>
-
- {(() => {
- const typeLabel =
- item.Type === "Series"
- ? "TV Show"
- : item.Type === "Movie"
- ? "Movie"
- : item.Type || "";
+ navigateToItem(item)}>
+
+ {(() => {
+ 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 "";
- }
- })()}
-
+ 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 "";
+ }
+ })()}
+
+
{/* Controls Section */}
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 60a248c7..80ead77a 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -225,7 +225,7 @@ export const DownloadItems: React.FC = ({
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,
}),
);
diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx
index 5ffce752..b90c4836 100644
--- a/components/downloads/DownloadCard.tsx
+++ b/components/downloads/DownloadCard.tsx
@@ -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 */}
-
- {process.status === "downloading" && (
+ {/* Action buttons in bottom right corner */}
+
+ {process.status === "downloading" && Platform.OS !== "ios" && (
handlePause(process.id)}
className='p-1'
@@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
- {process.status === "paused" && (
+ {process.status === "paused" && Platform.OS !== "ios" && (
handleResume(process.id)}
className='p-1'
diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx
index 64a9dbfa..a2cd32ba 100644
--- a/components/series/SeriesActions.tsx
+++ b/components/series/SeriesActions.tsx
@@ -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 (
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index e0ca1147..d4d7e598 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -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) {
diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js
index 83e1fb69..af24d642 100644
--- a/plugins/withRNBackgroundDownloader.js
+++ b/plugins/withRNBackgroundDownloader.js
@@ -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,
);
});
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 24675f7a..3e21165b 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -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([]);
@@ -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);
+ },
};
}
diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts
index ee74b25d..cff87ddf 100644
--- a/providers/Downloads/types.ts
+++ b/providers/Downloads/types.ts
@@ -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;
};
diff --git a/translations/en.json b/translations/en.json
index f6efdc68..88e2f120 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -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",
diff --git a/utils/log.tsx b/utils/log.tsx
index 88ca475e..956f1fb0 100644
--- a/utils/log.tsx
+++ b/utils/log.tsx
@@ -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) {