294 lines
10 KiB
Plaintext
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
|