import { AkReqResponse } from '@/uni_modules/ak-req/index.uts' import { SUPA_URL } from '@/ak/config.uts' import supa, { supaReady } from '@/components/supadb/aksupainstance.uts' import { AkSupaStorageUploadBuilder } from '@/components/supadb/aksupa.uts' export type UploadAudioResult = { success : boolean url : string | null error ?: string | null durationMs ?: number | null mime ?: string | null sizeBytes ?: number | null bucket ?: string | null objectPath ?: string | null } export type UploadAudioOptions = { filename ?: string mime ?: string bucket ?: string folder ?: string userId ?: string upsert ?: boolean cacheControlSeconds ?: number objectPath ?: string metadata ?: UTSJSONObject | null } export class AudioUploadService { private static readonly DEFAULT_BUCKET = 'chat-audio' private static readonly DEFAULT_FOLDER = 'voices/app' private static async ensureSession() : Promise { try { const ready = await supaReady if (!ready) { console.warn('Supabase session not established for audio upload') } } catch (err) { console.error('Supabase session initialization error', err) } } private static normalizeFilename(filename : string, mime : string) : string { let base = filename if (base == null || base.trim().length === 0) { base = 'audio_' + Date.now() } base = base.trim() let cleaned = '' for (let i : Int = 0; i < base.length; i++) { const ch = base.charAt(i) const code = base.charCodeAt(i) if (code == null) continue const isDigit = code >= 48 && code <= 57 const isUpper = code >= 65 && code <= 90 const isLower = code >= 97 && code <= 122 const isAllowedSymbol = (ch === '-' || ch === '_' || ch === '.' ) if (isDigit || isUpper || isLower || isAllowedSymbol) { cleaned = cleaned + ch } else if (ch === ' ') { cleaned = cleaned + '_' } } if (cleaned.length === 0) { cleaned = 'audio_' + Date.now() } if (cleaned.indexOf('.') < 0) { cleaned = cleaned + AudioUploadService.extensionForMime(mime) } return cleaned } private static extensionForMime(mime : string) : string { if (mime === 'audio/mpeg' || mime === 'audio/mp3') return '.mp3' if (mime === 'audio/wav') return '.wav' if (mime === 'audio/ogg') return '.ogg' if (mime === 'audio/aac') return '.aac' return '.mp3' } private static sanitizeSegment(value : string) : string { let result = '' for (let i : Int = 0; i < value.length; i++) { const ch = value.charAt(i) const code = value.charCodeAt(i) if (code == null) continue const isDigit = code >= 48 && code <= 57 const isUpper = code >= 65 && code <= 90 const isLower = code >= 97 && code <= 122 const isAllowedSymbol = (ch === '-' || ch === '_' || ch === '.') if (isDigit || isUpper || isLower || isAllowedSymbol) { result = result + ch } } if (result.length === 0) return 'segment' return result } private static splitSegments(input : string) : Array { const segments = new Array() if (input == null) return segments const parts = input.split('/') for (let i : Int = 0; i < parts.length; i++) { const part = parts[i] if (part == null) continue const trimmed = part.trim() if (trimmed.length === 0) continue segments.push(AudioUploadService.sanitizeSegment(trimmed)) } return segments } private static formatTwoDigits(value : number) : string { if (value < 10) { return '0' + value } return value.toString() } private static buildDateSegments(date : Date) : Array { const segments = new Array() segments.push(date.getFullYear().toString()) segments.push(AudioUploadService.formatTwoDigits(date.getMonth() + 1)) segments.push(AudioUploadService.formatTwoDigits(date.getDate())) return segments } private static normalizeObjectPath(path : string, filename : string) : string { const trimmed = path.trim() if (trimmed.length === 0) { return filename } const useFolder = trimmed.endsWith('/') ? trimmed : trimmed + '/' const folderSegments = AudioUploadService.splitSegments(useFolder) folderSegments.push(filename) return folderSegments.join('/') } private static buildObjectPath(filename : string, opts : UploadAudioOptions | null) : string { if (opts != null && opts.objectPath != null && opts.objectPath.trim().length > 0) { return AudioUploadService.normalizeObjectPath(opts.objectPath!!, filename) } const folderValue = opts != null && opts.folder != null ? opts.folder!! : AudioUploadService.DEFAULT_FOLDER const segments = AudioUploadService.splitSegments(folderValue) if (opts != null && opts.userId != null && opts.userId.trim().length > 0) { segments.push('users') segments.push(AudioUploadService.sanitizeSegment(opts.userId!!)) } const dateSegments = AudioUploadService.buildDateSegments(new Date()) for (let i : Int = 0; i < dateSegments.length; i++) { segments.push(dateSegments[i]) } segments.push(filename) return segments.join('/') } private static buildPublicUrl(bucket : string, objectPath : string) : string { let base = SUPA_URL if (base.endsWith('/')) { base = base.substring(0, base.length - 1) } let normalizedPath = objectPath while (normalizedPath.startsWith('/')) { normalizedPath = normalizedPath.substring(1) } return base + '/storage/v1/object/public/' + bucket + '/' + normalizedPath } private static mergeJson(target : UTSJSONObject, source : UTSJSONObject) { const keys = UTSJSONObject.keys(source) for (let i : Int = 0; i < keys.length; i++) { const key = keys[i] target[key] = source.get(key) } } private static async fetchFileSize(filePath : string) : Promise { return await new Promise((resolve) => { let settled = false const finish = (value : number | null) => { if (settled) { return } settled = true resolve(value) } try { const fs = uni.getFileSystemManager() fs.getFileInfo({ filePath, success: (res ) => { if (res != null && typeof res.size === 'number') { finish(res.size as number) return } finish(null) }, fail: (_err) => { fs.stat({ path: filePath, recursive: false, success: (statRes ) => { let size : number | null = null const stats = statRes != null ? statRes.stats as Array : null if (stats != null) { for (let i : Int = 0; i < stats.length; i++) { const entry = stats[i] if (entry == null) { continue } const candidate = (entry as UTSJSONObject).get('size') as number | null if (candidate != null) { size = candidate break } } } finish(size) }, fail: () => finish(null) } as StatOptions) } } as GetFileInfoOptions) } catch (_fatal) { finish(null) } }) } static async uploadAudio(filePath : string, opts ?: UploadAudioOptions) : Promise { if (filePath == null || filePath.length === 0) { return { success: false, url: null, error: 'invalid file path' } } await AudioUploadService.ensureSession() const mime = (opts != null && opts.mime != null && opts.mime.trim().length > 0) ? opts.mime!! : 'audio/mpeg' const filenameInput = opts != null && opts.filename != null ? opts.filename!! : '' const filename = AudioUploadService.normalizeFilename(filenameInput, mime) const bucket = (opts != null && opts.bucket != null && opts.bucket.trim().length > 0) ? opts.bucket!! : AudioUploadService.DEFAULT_BUCKET const objectPath = AudioUploadService.buildObjectPath(filename, opts ?? null) const uploadOptions = {} as UTSJSONObject const cacheSeconds = (opts != null && typeof opts.cacheControlSeconds === 'number') ? opts.cacheControlSeconds!! : 3600 uploadOptions['cacheControl'] = cacheSeconds.toString() uploadOptions['contentType'] = mime if (opts != null && opts.upsert === true) { uploadOptions['x-upsert'] = 'true' } if (opts != null && opts.metadata != null) { AudioUploadService.mergeJson(uploadOptions, opts.metadata!!) } let response : AkReqResponse try { const builder = new AkSupaStorageUploadBuilder(supa, bucket) response = await builder .path(objectPath) .file(filePath) .options(uploadOptions) .upload() } catch (err) { const message = (err instanceof Error) ? err.message : 'upload failed' return { success: false, url: null, error: message } } if (!(response.status >= 200 && response.status < 300)) { return { success: false, url: null, error: 'upload failed', mime } } let finalKey = objectPath const data = response.data if (data instanceof UTSJSONObject) { const key = data.getString('Key') if (key != null && key.length > 0) { finalKey = key } } else if (data != null && typeof data === 'object') { const jsonData = data as UTSJSONObject const keyValue = jsonData.getString('Key') if (keyValue != null && keyValue.length > 0) { finalKey = keyValue } } const publicUrl = AudioUploadService.buildPublicUrl(bucket, finalKey) const sizeBytes = await AudioUploadService.fetchFileSize(filePath) return { success: true, url: publicUrl, mime, sizeBytes, bucket, objectPath: finalKey } } } export default AudioUploadService