Files
akmon/utils/audioUploadService.uts
2026-01-20 08:04:15 +08:00

294 lines
10 KiB
Plaintext

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<void> {
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<string> {
const segments = new Array<string>()
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<string> {
const segments = new Array<string>()
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<number | null> {
return await new Promise<number | null>((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<any> : 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<UploadAudioResult> {
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<any>
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