277 lines
8.0 KiB
TypeScript
277 lines
8.0 KiB
TypeScript
import * as FileSystem from 'expo-file-system';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { CacheInfo, Track } from '../types';
|
|
|
|
const CACHE_DIR = `${FileSystem.documentDirectory}audio_cache/`;
|
|
const CACHE_INFO_KEY = '@zen_box_cache_info';
|
|
const CACHE_ONLY_KEY = '@zen_box_cache_only';
|
|
|
|
let cacheInfo: CacheInfo = {};
|
|
|
|
export const initCache = async (): Promise<void> => {
|
|
try {
|
|
// 确保缓存目录存在
|
|
const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
|
|
if (!dirInfo.exists) {
|
|
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
|
}
|
|
|
|
// 加载缓存信息
|
|
const cachedInfo = await AsyncStorage.getItem(CACHE_INFO_KEY);
|
|
if (cachedInfo) {
|
|
cacheInfo = JSON.parse(cachedInfo);
|
|
}
|
|
} catch (error) {
|
|
console.error('初始化缓存失败:', error);
|
|
}
|
|
};
|
|
|
|
export const getCacheInfo = (): CacheInfo => {
|
|
return { ...cacheInfo };
|
|
};
|
|
|
|
export interface DownloadAllResult {
|
|
successCount: number;
|
|
skippedCount: number;
|
|
failedTracks: string[];
|
|
total: number;
|
|
}
|
|
|
|
const generateCacheKey = (url: string): string => {
|
|
// 简单的 URL 哈希
|
|
let hash = 0;
|
|
for (let i = 0; i < url.length; i++) {
|
|
const char = url.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
return Math.abs(hash).toString(36) + '.mp3';
|
|
};
|
|
|
|
export const isCached = (url: string): boolean => {
|
|
return !!cacheInfo[url];
|
|
};
|
|
|
|
export const getCachedPath = (url: string): string | null => {
|
|
if (cacheInfo[url]) {
|
|
return cacheInfo[url].path;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
// 安全地编码 URL 中的路径部分,保留协议、域名等
|
|
const encodeUrlPath = (url: string): string => {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
// 只编码路径部分,不要编码整个 URL
|
|
const pathParts = parsedUrl.pathname.split('/');
|
|
const encodedPathParts = pathParts.map((part, index) => {
|
|
// 不要编码空字符串(开头的斜杠)
|
|
if (part === '') return part;
|
|
// 使用 encodeURIComponent 编码每个路径段
|
|
return encodeURIComponent(decodeURIComponent(part));
|
|
});
|
|
parsedUrl.pathname = encodedPathParts.join('/');
|
|
const finalUrl = parsedUrl.toString();
|
|
console.log(`[URL] 原始: ${url}`);
|
|
console.log(`[URL] 编码后: ${finalUrl}`);
|
|
return finalUrl;
|
|
} catch (error) {
|
|
console.error('[URL] 编码失败:', error);
|
|
// 如果解析失败,尝试简单编码
|
|
try {
|
|
const encoded = encodeURI(url);
|
|
console.log(`[URL] 简单编码: ${encoded}`);
|
|
return encoded;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const downloadAndCache = async (
|
|
url: string,
|
|
onProgress?: (progress: number) => void
|
|
): Promise<string> => {
|
|
const maxRetries = 3;
|
|
let retryCount = 0;
|
|
|
|
// 使用原始 URL 作为缓存键,但下载时使用编码后的 URL
|
|
const cacheKey = generateCacheKey(url);
|
|
const cachePath = `${CACHE_DIR}${cacheKey}`;
|
|
|
|
// 对 URL 进行编码,特别处理中文路径
|
|
const encodedUrl = encodeUrlPath(url);
|
|
|
|
while (retryCount < maxRetries) {
|
|
try {
|
|
console.log(`[下载] 开始下载: ${encodedUrl}`);
|
|
|
|
// 添加更多下载选项
|
|
const downloadResumable = FileSystem.createDownloadResumable(
|
|
encodedUrl,
|
|
cachePath,
|
|
{
|
|
// 设置一些常用的请求头,兼容 OSS
|
|
headers: {
|
|
'User-Agent': 'ZenBox/1.0',
|
|
'Accept': 'audio/mpeg,audio/*;q=0.9,*/*;q=0.8',
|
|
},
|
|
},
|
|
(downloadProgress) => {
|
|
if (onProgress && downloadProgress.totalBytesWritten > 0 && downloadProgress.totalBytesExpectedToWrite > 0) {
|
|
const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
|
|
onProgress(progress);
|
|
}
|
|
}
|
|
);
|
|
|
|
const downloadResult = await downloadResumable.downloadAsync();
|
|
|
|
if (downloadResult && downloadResult.status === 200) {
|
|
console.log(`[下载] 成功: ${encodedUrl}`);
|
|
|
|
const fileInfo = await FileSystem.getInfoAsync(cachePath);
|
|
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
|
|
|
|
cacheInfo[url] = {
|
|
path: cachePath,
|
|
size,
|
|
downloadedAt: Date.now(),
|
|
};
|
|
|
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
|
return cachePath;
|
|
}
|
|
|
|
console.error(`[下载] 失败,状态码: ${downloadResult?.status}`);
|
|
throw new Error(`下载失败,状态码: ${downloadResult?.status}`);
|
|
} catch (error) {
|
|
retryCount++;
|
|
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
|
|
|
|
if (retryCount >= maxRetries) {
|
|
console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`);
|
|
throw error;
|
|
}
|
|
|
|
// 指数退避重试
|
|
const backoffTime = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
|
|
console.log(`[下载] ${backoffTime}ms 后重试...`);
|
|
await sleep(backoffTime);
|
|
}
|
|
}
|
|
|
|
throw new Error('下载失败,已达到最大重试次数');
|
|
};
|
|
|
|
export const deleteCache = async (url: string): Promise<void> => {
|
|
try {
|
|
if (cacheInfo[url]) {
|
|
const path = cacheInfo[url].path;
|
|
await FileSystem.deleteAsync(path, { idempotent: true });
|
|
delete cacheInfo[url];
|
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
|
}
|
|
} catch (error) {
|
|
console.error('删除缓存失败:', error);
|
|
}
|
|
};
|
|
|
|
export const clearAllCache = async (): Promise<void> => {
|
|
try {
|
|
// 删除所有缓存文件
|
|
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
|
|
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
|
|
|
// 清空缓存信息
|
|
cacheInfo = {};
|
|
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
|
} catch (error) {
|
|
console.error('清除缓存失败:', error);
|
|
}
|
|
};
|
|
|
|
export const getCacheSize = async (): Promise<number> => {
|
|
try {
|
|
let totalSize = 0;
|
|
for (const url in cacheInfo) {
|
|
totalSize += cacheInfo[url].size;
|
|
}
|
|
return totalSize;
|
|
} catch (error) {
|
|
console.error('获取缓存大小失败:', error);
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
export const formatSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
};
|
|
|
|
export const downloadAllTracks = async (
|
|
tracks: Track[],
|
|
onProgress?: (completed: number, total: number, trackTitle: string) => void
|
|
): Promise<DownloadAllResult> => {
|
|
let successCount = 0;
|
|
let skippedCount = 0;
|
|
const failedTracks: string[] = [];
|
|
let completedCount = 0;
|
|
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
const track = tracks[i];
|
|
if (onProgress) {
|
|
onProgress(completedCount, tracks.length, track.title);
|
|
}
|
|
|
|
if (!isCached(track.src)) {
|
|
try {
|
|
await downloadAndCache(track.src);
|
|
successCount += 1;
|
|
} catch (error) {
|
|
console.error(`Failed to download ${track.title}:`, error);
|
|
failedTracks.push(track.title);
|
|
}
|
|
} else {
|
|
skippedCount += 1;
|
|
}
|
|
completedCount += 1;
|
|
}
|
|
if (onProgress) {
|
|
onProgress(tracks.length, tracks.length, '完成');
|
|
}
|
|
|
|
return {
|
|
successCount,
|
|
skippedCount,
|
|
failedTracks,
|
|
total: tracks.length,
|
|
};
|
|
};
|
|
|
|
export const getCacheOnlySetting = async (): Promise<boolean> => {
|
|
try {
|
|
const value = await AsyncStorage.getItem(CACHE_ONLY_KEY);
|
|
// 默认返回 true
|
|
return value !== 'false';
|
|
} catch (error) {
|
|
console.error('Failed to get cache only setting:', error);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
export const setCacheOnlySetting = async (value: boolean): Promise<void> => {
|
|
try {
|
|
await AsyncStorage.setItem(CACHE_ONLY_KEY, value.toString());
|
|
} catch (error) {
|
|
console.error('Failed to save cache only setting:', error);
|
|
throw error;
|
|
}
|
|
};
|