aboutsummaryrefslogtreecommitdiffstats
path: root/src/media-upload.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 05:54:23 -0700
committerAdam Soltys <[email protected]>2026-10-18 05:54:23 -0700
commitb9297719d294779a2ea14c77a67975308bace65c (patch)
treed005658191f0ce4dec1a97212d6f4525900b60c0 /src/media-upload.js
parent31661e28b1a010fa67b3a2527c43a129ab2a41cc (diff)
downloadcoinosv3-b9297719d294779a2ea14c77a67975308bace65c.tar.gz
coinosv3-b9297719d294779a2ea14c77a67975308bace65c.zip
HLS for long videos: vmedia serves a long clip as a 360p/720p stream made as it's watched (/vmedia/hls.m3u8), the feed and pager play long clips and posted .m3u8 streams through the browser's own HLS, and a long video posted from the app goes up as an HLS stream made on the device (master playlist on Blossom).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/media-upload.js')
-rw-r--r--src/media-upload.js46
1 files changed, 46 insertions, 0 deletions
diff --git a/src/media-upload.js b/src/media-upload.js
index c06771b..e45d2fe 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -115,3 +115,49 @@ export async function uploadPublicMedia(file, signEvent, {
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
}
+
+// An HLS stream (streamVideo) onto one host: the media files first, then
+// each playlist with its files' addresses written in, then the master
+// playlist naming those — its address is the post's. One host for the lot,
+// and one that serves a playlist as one (ditto and nostr.download do;
+// primal's says text/plain, nostr.build won't take it); a host that refuses
+// any piece is passed over for the next. `files` maps each name the
+// playlists use to its File.
+export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net'];
+export function rewritePlaylist(text, urls) {
+ const to = (p) => urls.get(p) || p;
+ return text.split('\n').map((line) => {
+ const l = line.trim();
+ if (l && !l.startsWith('#')) return to(l);
+ return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
+ }).join('\n');
+}
+export async function uploadStream({ files, master }, signEvent, { servers = STREAM_SERVERS, fetcher, onProgress = null } = {}) {
+ const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p));
+ const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master);
+ const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1;
+ let lastError = null;
+ for (const server of servers) {
+ let done = 0;
+ const put = async (file) => {
+ const url = await uploadPublicMedia(file, signEvent, {
+ servers: [server], ...(fetcher ? { fetcher } : {}),
+ onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))),
+ });
+ done += file.size;
+ if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in)
+ return url;
+ };
+ try {
+ const urls = new Map();
+ for (const p of media) urls.set(p, await put(files.get(p)));
+ for (const p of lists) {
+ const text = rewritePlaylist(await files.get(p).text(), urls);
+ urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' })));
+ }
+ const text = rewritePlaylist(await files.get(master).text(), urls);
+ return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' }));
+ } catch (error) { lastError = error; }
+ }
+ throw lastError || new MediaUploadError('failed');
+}