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
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
|
|
|