mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: choose download location (sd card)
This commit is contained in:
208
components/settings/StorageLocationPicker.tsx
Normal file
208
components/settings/StorageLocationPicker.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { StorageLocation } from "@/modules";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getAvailableStorageLocations } from "@/utils/storage";
|
||||
|
||||
interface StorageLocationPickerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StorageLocationPicker = forwardRef<
|
||||
BottomSheetModal,
|
||||
StorageLocationPickerProps
|
||||
>(({ onClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(
|
||||
settings.downloadStorageLocation || "internal",
|
||||
);
|
||||
|
||||
const { data: locations, isLoading } = useQuery({
|
||||
queryKey: ["storageLocations"],
|
||||
queryFn: getAvailableStorageLocations,
|
||||
enabled: Platform.OS === "android",
|
||||
});
|
||||
|
||||
const handleSelect = (location: StorageLocation) => {
|
||||
setSelectedId(location.id);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
updateSettings({ downloadStorageLocation: selectedId });
|
||||
toast.success(
|
||||
t("settings.storage.storage_location_updated", {
|
||||
defaultValue: "Storage location updated",
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (Platform.OS !== "android") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={ref}
|
||||
enableDynamicSizing
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
<View className='px-4 pt-2'>
|
||||
<Text className='text-lg font-semibold mb-1'>
|
||||
{t("settings.storage.select_storage_location", {
|
||||
defaultValue: "Select Storage Location",
|
||||
})}
|
||||
</Text>
|
||||
<Text className='text-sm text-neutral-500 mb-4'>
|
||||
{t("settings.storage.existing_downloads_note", {
|
||||
defaultValue:
|
||||
"Existing downloads will remain in their current location",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className='items-center justify-center py-8'>
|
||||
<ActivityIndicator size='large' />
|
||||
<Text className='mt-4 text-neutral-500'>
|
||||
{t("settings.storage.loading_storage", {
|
||||
defaultValue: "Loading storage options...",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
) : !locations || locations.length === 0 ? (
|
||||
<View className='items-center justify-center py-8'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("settings.storage.no_storage_found", {
|
||||
defaultValue: "No storage locations found",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{locations.map((location) => {
|
||||
const isSelected = selectedId === location.id;
|
||||
const freeSpaceGB = (location.freeSpace / 1024 ** 3).toFixed(2);
|
||||
const totalSpaceGB = (location.totalSpace / 1024 ** 3).toFixed(
|
||||
2,
|
||||
);
|
||||
const usedPercent = (
|
||||
((location.totalSpace - location.freeSpace) /
|
||||
location.totalSpace) *
|
||||
100
|
||||
).toFixed(0);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={location.id}
|
||||
onPress={() => handleSelect(location)}
|
||||
className={`p-4 mb-2 rounded-lg ${
|
||||
isSelected
|
||||
? "bg-purple-600/20 border border-purple-600"
|
||||
: "bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center justify-between'>
|
||||
<View className='flex-1'>
|
||||
<View className='flex-row items-center'>
|
||||
<Text className='text-base font-semibold'>
|
||||
{location.label}
|
||||
</Text>
|
||||
{location.type === "external" && (
|
||||
<View className='ml-2 px-2 py-0.5 bg-blue-600/30 rounded'>
|
||||
<Text className='text-xs text-blue-400'>
|
||||
{t("settings.storage.removable", {
|
||||
defaultValue: "Removable",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className='text-sm text-neutral-500 mt-1'>
|
||||
{t("settings.storage.space_info", {
|
||||
defaultValue:
|
||||
"{{free}} GB free of {{total}} GB ({{used}}% used)",
|
||||
free: freeSpaceGB,
|
||||
total: totalSpaceGB,
|
||||
used: usedPercent,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View className='w-6 h-6 rounded-full bg-purple-600 items-center justify-center ml-2'>
|
||||
<Text className='text-white text-xs'>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
<View className='flex-row gap-x-2 py-4'>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className='flex-1 py-3 rounded-lg bg-neutral-800 items-center'
|
||||
>
|
||||
<Text className='text-white font-semibold'>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
className='flex-1 py-3 rounded-lg bg-purple-600 items-center'
|
||||
disabled={!selectedId}
|
||||
>
|
||||
<Text className='text-white font-semibold'>
|
||||
{t("common.confirm", { defaultValue: "Confirm" })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
@@ -6,14 +8,19 @@ import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStorageLabel } from "@/utils/storage";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { StorageLocationPicker } from "./StorageLocationPicker";
|
||||
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const { data: size } = useQuery({
|
||||
queryKey: ["appSize"],
|
||||
@@ -29,6 +36,12 @@ export const StorageSettings = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: storageLabel } = useQuery({
|
||||
queryKey: ["storageLabel", settings.downloadStorageLocation],
|
||||
queryFn: () => getStorageLabel(settings.downloadStorageLocation),
|
||||
enabled: Platform.OS === "android",
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
@@ -102,14 +115,32 @@ export const StorageSettings = () => {
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<>
|
||||
{Platform.OS === "android" && (
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("settings.storage.download_location", {
|
||||
defaultValue: "Download Location",
|
||||
})}
|
||||
value={storageLabel || "Internal Storage"}
|
||||
onPress={() => bottomSheetModalRef.current?.present()}
|
||||
/>
|
||||
</ListGroup>
|
||||
)}
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StorageLocationPicker
|
||||
ref={bottomSheetModalRef}
|
||||
onClose={() => bottomSheetModalRef.current?.dismiss()}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,16 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.IBinder
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.util.Log
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
|
||||
data class DownloadTaskInfo(
|
||||
val url: String,
|
||||
@@ -142,6 +147,105 @@ class BackgroundDownloaderModule : Module() {
|
||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
|
||||
try {
|
||||
val storageLocations = mutableListOf<Map<String, Any>>()
|
||||
|
||||
// Use getExternalFilesDirs which works reliably across all Android versions
|
||||
// This returns app-specific directories on both internal and external storage
|
||||
val externalDirs = context.getExternalFilesDirs(null)
|
||||
|
||||
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
|
||||
|
||||
// Also check with StorageManager for additional info
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
val volumes = storageManager.storageVolumes
|
||||
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
|
||||
for ((i, vol) in volumes.withIndex()) {
|
||||
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
|
||||
}
|
||||
}
|
||||
|
||||
for ((index, dir) in externalDirs.withIndex()) {
|
||||
try {
|
||||
if (dir == null) {
|
||||
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dir.exists()) {
|
||||
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
|
||||
continue
|
||||
}
|
||||
|
||||
val isPrimary = index == 0
|
||||
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
|
||||
|
||||
// Get volume UUID for better identification
|
||||
val volumeId = if (isPrimary) {
|
||||
"internal"
|
||||
} else {
|
||||
// Try to get a stable UUID for the SD card
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
try {
|
||||
val storageVolume = storageManager.getStorageVolume(dir)
|
||||
storageVolume?.uuid ?: "sdcard_$index"
|
||||
} catch (e: Exception) {
|
||||
"sdcard_$index"
|
||||
}
|
||||
} else {
|
||||
"sdcard_$index"
|
||||
}
|
||||
}
|
||||
|
||||
// Get human-readable label
|
||||
val label = if (isPrimary) {
|
||||
"Internal Storage"
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
try {
|
||||
val storageVolume = storageManager.getStorageVolume(dir)
|
||||
storageVolume?.getDescription(context) ?: "SD Card"
|
||||
} catch (e: Exception) {
|
||||
"SD Card"
|
||||
}
|
||||
} else {
|
||||
"SD Card"
|
||||
}
|
||||
}
|
||||
|
||||
val totalSpace = dir.totalSpace
|
||||
val freeSpace = dir.freeSpace
|
||||
|
||||
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
|
||||
|
||||
storageLocations.add(
|
||||
mapOf(
|
||||
"id" to volumeId,
|
||||
"path" to dir.absolutePath,
|
||||
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
|
||||
"label" to label,
|
||||
"totalSpace" to totalSpace,
|
||||
"freeSpace" to freeSpace
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
|
||||
promise.resolve(storageLocations)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
|
||||
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
StorageLocation,
|
||||
} from "./src/BackgroundDownloader.types";
|
||||
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface BackgroundDownloader {
|
||||
cancelQueuedDownload(url: string): void;
|
||||
cancelAllDownloads(): void;
|
||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
@@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = {
|
||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||
},
|
||||
|
||||
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
|
||||
return await BackgroundDownloaderModule.getAvailableStorageLocations();
|
||||
},
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
): EventSubscription {
|
||||
@@ -106,4 +112,5 @@ export type {
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
StorageLocation,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,15 @@ export interface ActiveDownload {
|
||||
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||
}
|
||||
|
||||
export interface StorageLocation {
|
||||
id: string;
|
||||
path: string;
|
||||
type: "internal" | "external";
|
||||
label: string;
|
||||
totalSpace: number;
|
||||
freeSpace: number;
|
||||
}
|
||||
|
||||
export interface BackgroundDownloaderModuleType {
|
||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||
@@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType {
|
||||
cancelQueuedDownload(url: string): void;
|
||||
cancelAllDownloads(): void;
|
||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||
addListener(
|
||||
eventName: string,
|
||||
listener: (event: any) => void,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
StorageLocation,
|
||||
} from "./background-downloader";
|
||||
// Background Downloader
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
@@ -6,16 +6,20 @@ import type {
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { fetchAndParseSegments } from "@/utils/segments";
|
||||
import { filePathToUri } from "@/utils/storage";
|
||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
||||
import { generateFilename } from "./utils";
|
||||
|
||||
/**
|
||||
* Downloads trickplay images for an item
|
||||
* @param item - The item to download trickplay images for
|
||||
* @param storagePath - Optional custom storage path (for Android SD card support)
|
||||
* @returns TrickPlayData with path and size, or undefined if not available
|
||||
*/
|
||||
export async function downloadTrickplayImages(
|
||||
item: BaseItemDto,
|
||||
storagePath?: string,
|
||||
): Promise<TrickPlayData | undefined> {
|
||||
const trickplayInfo = getTrickplayInfo(item);
|
||||
if (!trickplayInfo || !item.Id) {
|
||||
@@ -23,7 +27,11 @@ export async function downloadTrickplayImages(
|
||||
}
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||
|
||||
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
||||
const trickplayDir = storagePath
|
||||
? new Directory(filePathToUri(storagePath), `${filename}_trickplay`)
|
||||
: new Directory(Paths.document, `${filename}_trickplay`);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!trickplayDir.exists) {
|
||||
@@ -69,12 +77,17 @@ export async function downloadTrickplayImages(
|
||||
|
||||
/**
|
||||
* Downloads external subtitle files and updates their delivery URLs to local paths
|
||||
* @param mediaSource - The media source containing subtitle information
|
||||
* @param item - The item to download subtitles for
|
||||
* @param apiBasePath - The base path for the API
|
||||
* @param storagePath - Optional custom storage path (for Android SD card support)
|
||||
* @returns Updated media source with local subtitle paths
|
||||
*/
|
||||
export async function downloadSubtitles(
|
||||
mediaSource: MediaSourceInfo,
|
||||
item: BaseItemDto,
|
||||
apiBasePath: string,
|
||||
storagePath?: string,
|
||||
): Promise<MediaSourceInfo> {
|
||||
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
||||
(stream) =>
|
||||
@@ -91,10 +104,17 @@ export async function downloadSubtitles(
|
||||
|
||||
const url = apiBasePath + subtitle.DeliveryUrl;
|
||||
const extension = subtitle.Codec || "srt";
|
||||
const destination = new File(
|
||||
Paths.document,
|
||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||
);
|
||||
|
||||
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
||||
const destination = storagePath
|
||||
? new File(
|
||||
filePathToUri(storagePath),
|
||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||
)
|
||||
: new File(
|
||||
Paths.document,
|
||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||
);
|
||||
|
||||
// Skip if already exists
|
||||
if (destination.exists) {
|
||||
@@ -208,13 +228,21 @@ export async function downloadAdditionalAssets(params: {
|
||||
api: Api;
|
||||
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
||||
storagePath?: string;
|
||||
}): Promise<{
|
||||
trickPlayData?: TrickPlayData;
|
||||
updatedMediaSource: MediaSourceInfo;
|
||||
introSegments?: MediaTimeSegment[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
}> {
|
||||
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
|
||||
const {
|
||||
item,
|
||||
mediaSource,
|
||||
api,
|
||||
saveImageFn,
|
||||
saveSeriesImageFn,
|
||||
storagePath,
|
||||
} = params;
|
||||
|
||||
// Run all downloads in parallel for speed
|
||||
const [
|
||||
@@ -223,11 +251,11 @@ export async function downloadAdditionalAssets(params: {
|
||||
segments,
|
||||
// Cover images (fire and forget, errors are logged)
|
||||
] = await Promise.all([
|
||||
downloadTrickplayImages(item),
|
||||
downloadTrickplayImages(item, storagePath),
|
||||
// Only download subtitles for non-transcoded streams
|
||||
mediaSource.TranscodingUrl
|
||||
? Promise.resolve(mediaSource)
|
||||
: downloadSubtitles(mediaSource, item, api.basePath || ""),
|
||||
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
|
||||
item.Id
|
||||
? fetchSegments(item.Id, api)
|
||||
: Promise.resolve({
|
||||
|
||||
@@ -6,13 +6,16 @@ import { File, Paths } from "expo-file-system";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import { toast } from "sonner-native";
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { BackgroundDownloader } from "@/modules";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { getStoragePath } from "@/utils/storage";
|
||||
import { downloadAdditionalAssets } from "../additionalDownloads";
|
||||
import {
|
||||
clearAllDownloadedItems,
|
||||
@@ -49,6 +52,7 @@ export function useDownloadOperations({
|
||||
onDataChange,
|
||||
}: UseDownloadOperationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
@@ -79,6 +83,12 @@ export function useDownloadOperations({
|
||||
return;
|
||||
}
|
||||
|
||||
// Get storage path if custom location is set
|
||||
let storagePath: string | undefined;
|
||||
if (Platform.OS === "android" && settings.downloadStorageLocation) {
|
||||
storagePath = await getStoragePath(settings.downloadStorageLocation);
|
||||
}
|
||||
|
||||
// Download all additional assets BEFORE starting native video download
|
||||
const additionalAssets = await downloadAdditionalAssets({
|
||||
item,
|
||||
@@ -86,6 +96,7 @@ export function useDownloadOperations({
|
||||
api,
|
||||
saveImageFn: saveImage,
|
||||
saveSeriesImageFn: saveSeriesPrimaryImage,
|
||||
storagePath,
|
||||
});
|
||||
|
||||
// Ensure URL is absolute (not relative) before storing
|
||||
@@ -119,10 +130,19 @@ export function useDownloadOperations({
|
||||
// Add to processes
|
||||
setProcesses((prev) => [...prev, jobStatus]);
|
||||
|
||||
// Generate destination path
|
||||
// Generate destination path using custom storage location if set
|
||||
const filename = generateFilename(item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const destinationPath = uriToFilePath(videoFile.uri);
|
||||
let destinationPath: string;
|
||||
|
||||
if (storagePath) {
|
||||
// Use custom storage location
|
||||
destinationPath = `${storagePath}/${filename}.mp4`;
|
||||
console.log(`[DOWNLOAD] Using custom storage: ${destinationPath}`);
|
||||
} else {
|
||||
// Use default Paths.document
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
destinationPath = uriToFilePath(videoFile.uri);
|
||||
}
|
||||
|
||||
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
||||
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
||||
|
||||
@@ -145,6 +145,7 @@ export type Settings = {
|
||||
marlinServerUrl?: string;
|
||||
openInVLC?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
downloadStorageLocation?: string;
|
||||
defaultBitrate?: Bitrate;
|
||||
libraryOptions: LibraryOptions;
|
||||
defaultAudioLanguage: CultureDto | null;
|
||||
@@ -203,6 +204,7 @@ export const defaultValues: Settings = {
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
downloadQuality: DownloadOptions[0],
|
||||
downloadStorageLocation: undefined,
|
||||
defaultBitrate: BITRATES[0],
|
||||
libraryOptions: {
|
||||
display: "list",
|
||||
|
||||
116
utils/storage.ts
Normal file
116
utils/storage.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Paths } from "expo-file-system";
|
||||
import { Platform } from "react-native";
|
||||
import { BackgroundDownloader, type StorageLocation } from "@/modules";
|
||||
|
||||
let cachedStorageLocations: StorageLocation[] | null = null;
|
||||
|
||||
/**
|
||||
* Get all available storage locations (Android only)
|
||||
* Returns cached result on subsequent calls
|
||||
*/
|
||||
export async function getAvailableStorageLocations(): Promise<
|
||||
StorageLocation[]
|
||||
> {
|
||||
if (Platform.OS !== "android") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cachedStorageLocations !== null) {
|
||||
return cachedStorageLocations;
|
||||
}
|
||||
|
||||
try {
|
||||
const locations = await BackgroundDownloader.getAvailableStorageLocations();
|
||||
cachedStorageLocations = locations;
|
||||
return locations;
|
||||
} catch (error) {
|
||||
console.error("Failed to get storage locations:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached storage locations
|
||||
* Useful when storage configuration might have changed
|
||||
*/
|
||||
export function clearStorageLocationsCache(): void {
|
||||
cachedStorageLocations = null;
|
||||
console.log("[Storage] Cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a simplified label for a storage location ID
|
||||
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
||||
* @returns Human-readable label (e.g., "Internal Storage", "SD Card")
|
||||
*/
|
||||
export async function getStorageLabel(storageId?: string): Promise<string> {
|
||||
if (!storageId || Platform.OS !== "android") {
|
||||
return "Internal Storage";
|
||||
}
|
||||
|
||||
const locations = await getAvailableStorageLocations();
|
||||
const location = locations.find((loc) => loc.id === storageId);
|
||||
|
||||
return location?.label || "Internal Storage";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filesystem path for a storage location ID
|
||||
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
||||
* @returns The filesystem path, or default path if not found
|
||||
*/
|
||||
export async function getStoragePath(storageId?: string): Promise<string> {
|
||||
if (!storageId || Platform.OS !== "android") {
|
||||
return getDefaultStoragePath();
|
||||
}
|
||||
|
||||
const locations = await getAvailableStorageLocations();
|
||||
const location = locations.find((loc) => loc.id === storageId);
|
||||
|
||||
if (!location) {
|
||||
console.warn(`Storage location not found: ${storageId}, using default`);
|
||||
return getDefaultStoragePath();
|
||||
}
|
||||
|
||||
return location.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default storage path (current behavior using Paths.document)
|
||||
* @returns The default storage path
|
||||
*/
|
||||
export function getDefaultStoragePath(): string {
|
||||
// Paths.document returns a Directory with a URI like "file:///data/user/0/..."
|
||||
// We need to extract the actual path
|
||||
const uri = Paths.document.uri;
|
||||
return uri.replace("file://", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a storage location by ID
|
||||
* @param storageId - The storage location ID
|
||||
* @returns The storage location or undefined if not found
|
||||
*/
|
||||
export async function getStorageLocationById(
|
||||
storageId?: string,
|
||||
): Promise<StorageLocation | undefined> {
|
||||
if (!storageId || Platform.OS !== "android") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const locations = await getAvailableStorageLocations();
|
||||
return locations.find((loc) => loc.id === storageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plain file path to file:// URI
|
||||
* Required for expo-file-system File constructor
|
||||
* @param path - The file path
|
||||
* @returns The file:// URI
|
||||
*/
|
||||
export function filePathToUri(path: string): string {
|
||||
if (path.startsWith("file://")) {
|
||||
return path;
|
||||
}
|
||||
return `file://${path}`;
|
||||
}
|
||||
Reference in New Issue
Block a user