import { fetchApi } from '@/lib/server-api-util'; import * as BackgroundFetch from 'expo-background-fetch'; import * as FileSystem from 'expo-file-system'; import * as ImageManipulator from 'expo-image-manipulator'; import * as MediaLibrary from 'expo-media-library'; import * as TaskManager from 'expo-task-manager'; import pLimit from 'p-limit'; import { Alert } from 'react-native'; import { transformData } from '../utils/objectFlat'; type ExtendedAsset = MediaLibrary.Asset & { exif?: Record; }; const BACKGROUND_UPLOAD_TASK = 'background-upload-task'; // 设置最大并发数 const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件 // 在 CONCURRENCY_LIMIT 定义后添加 const limit = pLimit(CONCURRENCY_LIMIT); // 获取文件扩展名 const getFileExtension = (filename: string) => { return filename.split('.').pop()?.toLowerCase() || ''; }; // 获取 MIME 类型 const getMimeType = (filename: string, isVideo: boolean) => { if (!isVideo) return 'image/jpeg'; const ext = getFileExtension(filename); switch (ext) { case 'mov': return 'video/quicktime'; case 'mp4': return 'video/mp4'; case 'm4v': return 'video/x-m4v'; default: return 'video/mp4'; // 默认值 } }; // 将 HEIC 图片转化 const convertHeicToJpeg = async (uri: string): Promise => { try { console.log('Starting HEIC to JPEG conversion for:', uri); // 1. 将文件复制到缓存目录 const cacheDir = FileSystem.cacheDirectory; if (!cacheDir) { throw new Error('Cache directory not available'); } // 创建唯一的文件名 const tempUri = `${cacheDir}${Date.now()}.heic`; // 复制文件到缓存目录 await FileSystem.copyAsync({ from: uri, to: tempUri }); // 2. 检查文件是否存在 const fileInfo = await FileSystem.getInfoAsync(tempUri); if (!fileInfo.exists) { throw new Error('Temporary file was not created'); } // 3. 读取文件为 base64 const base64 = await FileSystem.readAsStringAsync(tempUri, { encoding: FileSystem.EncodingType.Base64, }); if (!base64) { throw new Error('Failed to read file as base64'); } // 4. 创建 Blob const response = await fetch(`data:image/jpeg;base64,${base64}`); if (!response.ok) { throw new Error(`Fetch failed with status ${response.status}`); } const blob = await response.blob(); if (!blob || blob.size === 0) { throw new Error('Failed to create blob from base64'); } // 5. 创建文件名 const originalName = uri.split('/').pop() || 'converted'; const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg'); console.log('Successfully converted HEIC to JPEG:', filename); // 清理临时文件 try { await FileSystem.deleteAsync(tempUri, { idempotent: true }); } catch (cleanupError) { console.warn('Failed to clean up temporary file:', cleanupError); } return new File([blob], filename, { type: 'image/jpeg' }); } catch (error: unknown) { console.error('Detailed HEIC conversion error:', { error: error instanceof Error ? { message: error.message, name: error.name, stack: error.stack } : error, uri: uri }); throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`); } }; // 获取指定时间范围内的媒体文件(包含 EXIF 信息) export const getMediaByDateRange = async (startDate: Date, endDate: Date) => { try { const { status } = await MediaLibrary.requestPermissionsAsync(); if (status !== 'granted') { console.warn('Media library permission not granted'); return []; } const media = await MediaLibrary.getAssetsAsync({ mediaType: ['photo', 'video'], first: 100, sortBy: [MediaLibrary.SortBy.creationTime], createdAfter: startDate.getTime(), createdBefore: endDate.getTime(), }); // 为每个资源获取完整的 EXIF 信息 const assetsWithExif = await Promise.all( media.assets.map(async (asset) => { try { const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id); return { ...asset, exif: assetInfo.exif || null, location: assetInfo.location || null }; } catch (error) { console.warn(`Failed to get EXIF for asset ${asset.id}:`, error); return asset; } }) ); return assetsWithExif; } catch (error) { console.error('Error in getMediaByDateRange:', error); return []; } }; // 压缩图片 const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => { try { const manipResult = await ImageManipulator.manipulateAsync( uri, [ { resize: { width: 1200, height: 1200, }, }, ], { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: false, } ); const response = await fetch(manipResult.uri); const blob = await response.blob(); const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`; const file = new File([blob], filename, { type: 'image/jpeg' }); return { uri: manipResult.uri, file }; } catch (error) { throw error; } }; // 获取上传URL const getUploadUrl = async (file: File, metadata: Record = {}): Promise<{ upload_url: string; file_id: string }> => { const body = { filename: file.name, content_type: file.type, file_size: file.size, metadata: { ...metadata, originalName: file.name, fileType: file.type.startsWith('video/') ? 'video' : 'image', isCompressed: 'true', }, }; return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", { method: 'POST', body: JSON.stringify(body) }); }; // 确认上传 const confirmUpload = async (file_id: string) => { return await fetchApi('/file/confirm-upload', { method: 'POST', body: JSON.stringify({ file_id }) }); }; // 新增素材 const addMaterial = async (file: string, compressFile: string) => { await fetchApi('/material', { method: 'POST', body: JSON.stringify([{ "file_id": file, "preview_file_id": compressFile }]) }).catch((error) => { }) } // 上传文件到URL const uploadFile = async (file: File, uploadUrl: string): Promise => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', file.type); // 进度监听 xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => { reject(new Error('Network error during upload')); }; xhr.send(file); }); }; // 检查并请求媒体库权限 const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => { try { const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync(); // 如果已经授权,直接返回 if (status === 'granted' && accessPrivileges === 'all') { return { hasPermission: true, status }; } // 如果没有授权,请求权限 const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync(); const isGranted = newStatus === 'granted' && newPrivileges === 'all'; if (!isGranted) { console.log('Media library permission not granted or limited access'); } return { hasPermission: isGranted, status: newStatus }; } catch (error) { return { hasPermission: false }; } }; // 提取视频的首帧进行压缩并上传 const uploadVideoThumbnail = async (asset: ExtendedAsset) => { try { const manipResult = await compressImage(asset.uri); const response = await fetch(manipResult.uri); const blob = await response.blob(); const filename = asset.filename ? `compressed_${asset.filename}` : `image_${Date.now()}_compressed.jpg`; const compressedFile = new File([blob], filename, { type: 'image/jpeg' }); const { upload_url, file_id } = await getUploadUrl(compressedFile, { originalUri: asset.uri, creationTime: asset.creationTime, mediaType: 'image', isCompressed: true }); await uploadFile(compressedFile, upload_url); await confirmUpload(file_id); console.log('视频首帧文件上传成功:', { fileId: file_id, filename: compressedFile.name, type: compressedFile.type }); return { success: true, file_id }; } catch (error) { return { success: false, error }; } }; // 处理单个媒体文件上传 const processMediaUpload = async (asset: ExtendedAsset) => { try { // 检查权限 const { hasPermission } = await checkMediaLibraryPermission(); if (!hasPermission) { throw new Error('No media library permission'); } const isVideo = asset.mediaType === 'video'; // 上传原始文件 const uploadOriginalFile = async () => { try { let fileToUpload: File; const isVideo = asset.mediaType === 'video'; const mimeType = getMimeType(asset.filename || '', isVideo); // 生成文件名,保留原始扩展名 let filename = asset.filename || `${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`; // 处理 HEIC 格式 if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) { fileToUpload = await convertHeicToJpeg(asset.uri); filename = filename.replace(/\.(heic|heif)$/i, '.jpg'); } else { // 获取资源信息 const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, { shouldDownloadFromNetwork: true }); if (!assetInfo.localUri) { throw new Error('无法获取资源的本地路径'); } // 获取文件扩展名 const fileExtension = getFileExtension(assetInfo.filename || '') || (isVideo ? 'mp4' : 'jpg'); // 确保文件名有正确的扩展名 if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) { const baseName = filename.split('.')[0]; filename = `${baseName}.${fileExtension}`; } // 获取文件内容 const response = await fetch(assetInfo.localUri); const blob = await response.blob(); // 创建文件对象 fileToUpload = new File([blob], filename, { type: mimeType }); console.log('文件准备上传:', { name: fileToUpload.name, type: fileToUpload.type, size: fileToUpload.size }); } // 准备元数据 let exifData = {}; if (asset.exif) { try { exifData = transformData({ ...asset, exif: { ...asset.exif, '{MakerApple}': undefined } }); } catch (exifError) { console.warn('处理 EXIF 数据时出错:', exifError); } } // 获取上传 URL const { upload_url, file_id } = await getUploadUrl(fileToUpload, { originalUri: asset.uri, creationTime: asset.creationTime, mediaType: isVideo ? 'video' : 'image', isCompressed: false, ...exifData, GPSVersionID: undefined }); // 上传文件 await uploadFile(fileToUpload, upload_url); await confirmUpload(file_id); console.log('文件上传成功:', { fileId: file_id, filename: fileToUpload.name, type: fileToUpload.type }); return { success: true, file_id, filename: fileToUpload.name }; } catch (error: any) { const errorMessage = error instanceof Error ? error.message : '未知错误'; console.error('上传原始文件时出错:', { error: errorMessage, assetId: asset.id, filename: asset.filename, uri: asset.uri }); throw new Error(`上传失败: ${errorMessage}`); } }; // 上传压缩文件(仅图片) const uploadCompressedFile = async () => { if (isVideo) return { success: true, file_id: null }; // 视频不压缩 try { const manipResult = await compressImage(asset.uri); const response = await fetch(manipResult.uri); const blob = await response.blob(); const filename = asset.filename ? `compressed_${asset.filename}` : `image_${Date.now()}_compressed.jpg`; const compressedFile = new File([blob], filename, { type: 'image/jpeg' }); const { upload_url, file_id } = await getUploadUrl(compressedFile, { originalUri: asset.uri, creationTime: asset.creationTime, mediaType: 'image', isCompressed: true }); await uploadFile(compressedFile, upload_url); await confirmUpload(file_id); return { success: true, file_id }; } catch (error) { return { success: false, error }; } }; // 先上传原始文件 const originalResult = await uploadOriginalFile(); // 如果是图片,再上传压缩文件 let compressedResult = { success: true, file_id: null }; if (!isVideo) { compressedResult = await uploadCompressedFile(); // 添加素材 addMaterial(originalResult.file_id, compressedResult?.file_id || ''); } else { // 上传压缩首帧 uploadVideoThumbnail(asset) } return { originalSuccess: originalResult.success, compressedSuccess: compressedResult.success, fileIds: { original: originalResult.file_id, compressed: compressedResult.file_id } }; } catch (error: any) { if (error.message === 'No media library permission') { throw error; } return { originalSuccess: false, compressedSuccess: false, error: error.message }; } }; // 注册后台任务 export const registerBackgroundUploadTask = async () => { try { // 检查是否已经注册了任务 const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK); if (isRegistered) { await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK); } // 注册后台任务 await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, { minimumInterval: 15 * 60, // 15 分钟 stopOnTerminate: false, // 应用退出后继续运行 startOnBoot: true, // 设备启动后自动启动 }); console.log('Background task registered'); return true; } catch (error) { console.error('Error registering background task:', error); return false; } }; // 定义后台任务 TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => { try { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 获取最近24小时的媒体文件 const media = await getMediaByDateRange(oneDayAgo, now); if (media.length === 0) { console.log('No media files to upload'); return BackgroundFetch.BackgroundFetchResult.NoData; } // 处理媒体文件上传 const results = await triggerManualUpload(oneDayAgo, now); const successCount = results.filter(r => r.originalSuccess).length; console.log(`Background upload completed. Success: ${successCount}/${results.length}`); return successCount > 0 ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData; } catch (error) { console.error('Background task error:', error); return BackgroundFetch.BackgroundFetchResult.Failed; } }); // 手动触发上传 export const triggerManualUpload = async (startDate: Date, endDate: Date) => { try { const media = await getMediaByDateRange(startDate, endDate); if (media.length === 0) { Alert.alert('提示', '在指定时间范围内未找到媒体文件'); return []; } // 分离图片和视频 const photos = media.filter(item => item.mediaType === 'photo'); const videos = media.filter(item => item.mediaType === 'video'); console.log('videos11111111', videos); const results: any[] = []; // 处理所有图片(带并发控制) const processPhoto = async (item: any) => { try { const result = await processMediaUpload(item); results.push({ id: item.id, ...result }); } catch (error: any) { results.push({ id: item.id, originalSuccess: false, compressedSuccess: false, error: error.message }); } }; // 处理所有视频(带并发控制) const processVideo = async (item: any) => { try { const result = await processMediaUpload(item); results.push({ id: item.id, ...result }); } catch (error: any) { results.push({ id: item.id, originalSuccess: false, compressedSuccess: false, error: error.message }); } }; // 并发处理图片和视频 await Promise.all([ ...photos.map(photo => limit(() => processPhoto(photo))), ...videos.map(video => limit(() => processVideo(video))) ]); return results; } catch (error) { Alert.alert('错误', '上传过程中出现错误'); throw error; } };