Renames the repo directory and every podpuddle/PODPUDDLE reference across code, config, and docs to podsteadr/PODSTEADR (package names, Docker Compose project/service/volume names, env var names, UI/RSS strings). Existing Docker volume data (uploaded blobs, mediamtx recordings, the server's sqlite DB and its nostr identity key) was migrated to new podsteadr_-prefixed volumes with matching filenames so it isn't orphaned by the rename.
77 lines
2.5 KiB
TypeScript
77 lines
2.5 KiB
TypeScript
// Browser-direct Blossom (BUD-02) upload: hash locally, sign the kind-24242
|
|
// authorization with the user's NIP-07 extension, PUT straight to the blossom
|
|
// server. The file never transits podsteadr.
|
|
|
|
import { nip07 } from './nip07';
|
|
|
|
export async function sha256File(file: File, onProgress?: (frac: number) => void): Promise<string> {
|
|
// crypto.subtle needs the whole buffer; fine for podcast-sized mp4s (~2 GB cap).
|
|
const buf = await file.arrayBuffer();
|
|
onProgress?.(0.5);
|
|
const digest = await crypto.subtle.digest('SHA-256', buf);
|
|
onProgress?.(1);
|
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export interface BlossomUpload {
|
|
sha256: string;
|
|
size: number;
|
|
url: string;
|
|
}
|
|
|
|
export async function uploadToBlossom(
|
|
blossomUrl: string,
|
|
file: File,
|
|
sha256: string,
|
|
onProgress?: (frac: number) => void,
|
|
): Promise<BlossomUpload> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const auth = await nip07().signEvent({
|
|
kind: 24242,
|
|
created_at: now,
|
|
content: `Upload ${file.name}`,
|
|
tags: [
|
|
['t', 'upload'],
|
|
['x', sha256],
|
|
['expiration', String(now + 600)],
|
|
],
|
|
});
|
|
|
|
const base = blossomUrl.replace(/\/+$/, '');
|
|
// XHR instead of fetch for upload progress events.
|
|
await new Promise<void>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('PUT', `${base}/upload`);
|
|
xhr.setRequestHeader('authorization', `Nostr ${btoa(JSON.stringify(auth))}`);
|
|
xhr.setRequestHeader('content-type', file.type || 'video/mp4');
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
|
|
};
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
|
else reject(new Error(`blossom upload failed: ${xhr.status} ${xhr.responseText.slice(0, 200)}`));
|
|
};
|
|
xhr.onerror = () => reject(new Error('blossom upload failed: network error'));
|
|
xhr.send(file);
|
|
});
|
|
|
|
return { sha256, size: file.size, url: `${base}/${sha256}` };
|
|
}
|
|
|
|
/** Read the media duration (seconds) from a local file, for itunes:duration. */
|
|
export function probeDuration(file: File): Promise<number | null> {
|
|
return new Promise((resolve) => {
|
|
const el = document.createElement('video');
|
|
el.preload = 'metadata';
|
|
el.onloadedmetadata = () => {
|
|
URL.revokeObjectURL(el.src);
|
|
resolve(Number.isFinite(el.duration) ? Math.round(el.duration) : null);
|
|
};
|
|
el.onerror = () => {
|
|
URL.revokeObjectURL(el.src);
|
|
resolve(null);
|
|
};
|
|
el.src = URL.createObjectURL(file);
|
|
});
|
|
}
|