You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

335 lines
11 KiB

/** Compression runs entirely in-app before upload (`compress-upload-media`). Load `local-storage` before `./client.service`; the default export is lazily constructed so `client`↔`draft-event`↔this module cycles cannot run the constructor before `storage` is initialized. */
import storage from './local-storage.service'
import { compressMediaForUpload } from '@/lib/compress-upload-media'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
import {
buildClientNip94Pairs,
extractVideoNip94Preview,
mergeNip94Pairs,
nip94PairsToImetaTag
} from '@/lib/upload-nip94-imeta'
import { simplifyUrl } from '@/lib/url'
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
import { BlossomClient } from 'blossom-client-sdk'
import { z } from 'zod'
import client from './client.service'
type UploadOptions = {
onProgress?: (progressPercent: number) => void
signal?: AbortSignal
/** Fires synchronously before client-side compression (images/audio/video). */
onCompressStart?: () => void
/** Fires after compression finishes (or throws), before the HTTP upload. */
onCompressEnd?: () => void
/** 0–100 during local compression (encode), not network upload. */
onCompressProgress?: (percent: number) => void
/**
* Reject when the compressed file size (bytes sent to the server) exceeds this limit.
* Checked after `compressMediaForUpload`, before HTTP upload.
*/
maxCompressedSizeMb?: number
}
export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
class MediaUploadService {
static instance: MediaUploadService
/** Set in constructor so we do not read `storage` at class field init (circular import TDZ with client.service → draft-event → this module). */
private serviceConfig!: TMediaUploadServiceConfig
private nip96ServiceUploadUrlMap = new Map<string, string | undefined>()
private imetaTagMap = new Map<string, string[]>()
constructor() {
if (MediaUploadService.instance) {
return MediaUploadService.instance
}
this.serviceConfig = storage.getMediaUploadServiceConfig()
MediaUploadService.instance = this
return MediaUploadService.instance
}
setServiceConfig(config: TMediaUploadServiceConfig) {
this.serviceConfig = config
}
async upload(file: File, options?: UploadOptions) {
options?.onCompressStart?.()
let toUpload: File
try {
toUpload = await compressMediaForUpload(file, {
signal: options?.signal,
onCompressProgress: options?.onCompressProgress
})
} finally {
options?.onCompressEnd?.()
}
if (
options?.maxCompressedSizeMb !== undefined &&
toUpload.size > options.maxCompressedSizeMb * 1024 * 1024
) {
const mb = (toUpload.size / (1024 * 1024)).toFixed(1)
throw new Error(
`After compression the file is ${mb} MB; maximum allowed is ${options.maxCompressedSizeMb} MB.`
)
}
try {
const diag =
import.meta.env.DEV ||
(typeof localStorage !== 'undefined' && localStorage.getItem('jumble-upload-log') === 'true')
if (diag) {
console.log('[media-upload] sending to server', {
backend: this.serviceConfig.type,
bytes: toUpload.size,
name: toUpload.name,
type: toUpload.type || '(empty)'
})
}
} catch {
// ignore
}
const videoPreviewPromise =
toUpload.type.startsWith('video/') ? extractVideoNip94Preview(toUpload) : Promise.resolve(null)
const uploadPromise =
this.serviceConfig.type === 'nip96'
? this.uploadByNip96(this.serviceConfig.service, toUpload, options)
: this.uploadByBlossom(toUpload, options)
const [videoPreview, result] = await Promise.all([videoPreviewPromise, uploadPromise])
const clientPairs = await buildClientNip94Pairs(toUpload, result.url, videoPreview)
if (videoPreview?.posterJpeg) {
try {
const posterResult =
this.serviceConfig.type === 'nip96'
? await this.uploadByNip96(this.serviceConfig.service, videoPreview.posterJpeg, options)
: await this.uploadByBlossom(videoPreview.posterJpeg, options)
clientPairs.push(['image', posterResult.url], ['thumb', posterResult.url])
} catch (e) {
logger.warn('Video poster frame upload failed; imeta may omit image/thumb', {
error: String(e)
})
}
}
const mergedTags = mergeNip94Pairs(clientPairs, result.tags)
this.imetaTagMap.set(result.url, nip94PairsToImetaTag(mergedTags))
return { url: result.url, tags: mergedTags }
}
private async uploadByBlossom(file: File, options?: UploadOptions) {
const pubkey = client.pubkey
const signer = async (draft: TDraftEvent) => {
if (!client.signer) {
throw new Error('You need to be logged in to upload media')
}
return client.signer.signEvent(draft)
}
if (!pubkey) {
throw new Error('You need to be logged in to upload media')
}
if (options?.signal?.aborted) {
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
}
options?.onProgress?.(0)
// Pseudo-progress: advance gradually until main upload completes
let pseudoProgress = 1
let pseudoTimer: number | undefined
const startPseudoProgress = () => {
if (pseudoTimer !== undefined) return
pseudoTimer = window.setInterval(() => {
// Cap pseudo progress to 90% until we get real completion
pseudoProgress = Math.min(pseudoProgress + 3, 90)
options?.onProgress?.(pseudoProgress)
if (pseudoProgress >= 90) {
stopPseudoProgress()
}
}, 300)
}
const stopPseudoProgress = () => {
if (pseudoTimer !== undefined) {
clearInterval(pseudoTimer)
pseudoTimer = undefined
}
}
startPseudoProgress()
const servers =
this.serviceConfig.type === 'blossom-preset'
? [this.serviceConfig.url]
: await client.fetchBlossomServerList(pubkey)
if (servers.length === 0) {
throw new Error('No Blossom services available')
}
const [mainServer, ...mirrorServers] = servers
const auth = await BlossomClient.createUploadAuth(signer, file, {
message: 'Uploading media file'
})
// first upload blob to main server
const blob = await BlossomClient.uploadBlob(mainServer, file, { auth })
// Main upload finished
stopPseudoProgress()
options?.onProgress?.(80)
if (mirrorServers.length > 0) {
await Promise.allSettled(
mirrorServers.map((server) => BlossomClient.mirrorBlob(server, blob, { auth }))
)
}
let tags: string[][] = []
const parseResult = z.array(z.array(z.string())).safeParse((blob as any).nip94 ?? [])
if (parseResult.success) {
tags = parseResult.data
}
options?.onProgress?.(100)
return { url: blob.url, tags }
}
private async uploadByNip96(service: string, file: File, options?: UploadOptions) {
if (options?.signal?.aborted) {
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
}
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
if (!uploadUrl) {
const response = await fetchWithTimeout(`${service}/.well-known/nostr/nip96.json`, {
signal: options?.signal,
timeoutMs: 15_000
})
if (!response.ok) {
throw new Error(
`${simplifyUrl(service)} does not work, please try another service in your settings`
)
}
const data = await response.json()
uploadUrl = data?.api_url
if (!uploadUrl) {
throw new Error(
`${simplifyUrl(service)} does not work, please try another service in your settings`
)
}
this.nip96ServiceUploadUrlMap.set(service, uploadUrl)
}
if (options?.signal?.aborted) {
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
}
const formData = new FormData()
formData.append('file', file)
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
// Use XMLHttpRequest for upload progress support
const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', uploadUrl as string)
xhr.responseType = 'json'
xhr.setRequestHeader('Authorization', auth)
let pseudoTimer: number | undefined
let pseudo = 0
const stopPseudo = () => {
if (pseudoTimer !== undefined) {
window.clearInterval(pseudoTimer)
pseudoTimer = undefined
}
}
const startPseudo = () => {
if (pseudoTimer !== undefined) return
pseudoTimer = window.setInterval(() => {
pseudo = Math.min(pseudo + 2, 92)
options?.onProgress?.(pseudo)
}, 220)
}
const handleAbort = () => {
stopPseudo()
try {
xhr.abort()
} catch {
// ignore
}
reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
}
if (options?.signal) {
if (options.signal.aborted) {
return handleAbort()
}
options.signal.addEventListener('abort', handleAbort, { once: true })
}
options?.onProgress?.(0)
startPseudo()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
stopPseudo()
const percent = Math.round((event.loaded / event.total) * 100)
options?.onProgress?.(percent)
}
}
xhr.onerror = () => {
stopPseudo()
reject(new Error('Network error'))
}
xhr.onload = () => {
stopPseudo()
if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response
try {
const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1]
if (url) {
options?.onProgress?.(100)
resolve({ url, tags })
} else {
reject(new Error('No url found'))
}
} catch (e) {
reject(e as Error)
}
} else {
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText))
}
}
xhr.send(formData)
})
return result
}
getImetaTagByUrl(url: string) {
return this.imetaTagMap.get(url)
}
}
/**
* Eager `new MediaUploadService()` at module load can run while `storage` is still in the TDZ:
* `client.service` (and its graph) may synchronously pull `draft-event` → this module again
* before static imports have finished binding. Lazily construct on first property access.
*/
function createMediaUploadServiceLazy(): MediaUploadService {
let inner: MediaUploadService | undefined
return new Proxy({} as MediaUploadService, {
get(_target, prop, receiver) {
if (!inner) inner = new MediaUploadService()
const v = Reflect.get(inner, prop, receiver) as unknown
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(inner) : v
}
})
}
const instance = createMediaUploadServiceLazy()
export default instance