feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user