fix: Fixed upload bugs, an improved nip96 support

This commit is contained in:
florian 2024-07-22 08:58:29 +02:00
parent 74b0f086f9
commit 37bc592a6d
9 changed files with 114 additions and 94 deletions

View File

@ -71,7 +71,7 @@ const AudioBlobList = ({ audioFiles, handleSelectBlob, selectedBlobs }: AudioBlo
</div> </div>
{blob.data.id3 && ( {blob.data.id3 && (
<div className="flex flex-col pb-1 md:pb-4 flex-grow"> <div className="flex flex-col pb-1 md:pb-4 flex-grow">
{blob.data.id3.title && <span className="font-bold">{blob.data.id3.title}</span>} {blob.data.id3.title && <span className=" text-accent">{blob.data.id3.title}</span>}
{blob.data.id3.artist && <span className=" text-sm"> {blob.data.id3.artist}</span>} {blob.data.id3.artist && <span className=" text-sm"> {blob.data.id3.artist}</span>}
{blob.data.id3.album && ( {blob.data.id3.album && (
<span className="text-sm"> <span className="text-sm">

View File

@ -140,7 +140,7 @@ const AudioPlayer: React.FC = () => {
<img className="w-12 h-12" src={currentSong.id3.cover} alt={currentSong.id3.title} /> <img className="w-12 h-12" src={currentSong.id3.cover} alt={currentSong.id3.title} />
</div> </div>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<div className="text-white">{currentSong.id3.title}</div> <div className="text-accent">{currentSong.id3.title}</div>
<div>{currentSong.id3.artist}</div> <div>{currentSong.id3.artist}</div>
</div> </div>
</> </>

View File

@ -8,6 +8,7 @@ import { transferBlob } from '../../utils/transfer';
import { useNDK } from '../../utils/ndk'; import { useNDK } from '../../utils/ndk';
import TagInput from '../TagInput'; import TagInput from '../TagInput';
import { allGenres } from '../../utils/genres'; import { allGenres } from '../../utils/genres';
import { useServerInfo } from '../../utils/useServerInfo';
export type FileEventData = { export type FileEventData = {
originalFile: File; originalFile: File;
@ -35,6 +36,7 @@ export type FileEventData = {
const FileEventEditor = ({ data }: { data: FileEventData }) => { const FileEventEditor = ({ data }: { data: FileEventData }) => {
const { signEventTemplate } = useNDK(); const { signEventTemplate } = useNDK();
const { serverInfo } = useServerInfo();
const [fileEventData, setFileEventData] = useState(data); const [fileEventData, setFileEventData] = useState(data);
const [selectedThumbnail, setSelectedThumbnail] = useState<string | undefined>(); const [selectedThumbnail, setSelectedThumbnail] = useState<string | undefined>();
@ -78,15 +80,15 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
} }
}, [fileEventData]); }, [fileEventData]);
function extractProtocolAndDomain(url: string): string | null { function extractDomain(url: string): string | null {
const regex = /^(https?:\/\/[^/]+)/; const regex = /^(https?:\/\/)([^/]+)/;
const match = url.match(regex); const match = url.match(regex);
return match ? match[0] : null; return match ? match[2]?.toLocaleLowerCase() : null;
} }
const publishSelectedThumbnailToAllOwnServers = async (): Promise<BlobDescriptor | undefined> => { const publishSelectedThumbnailToAllOwnServers = async (): Promise<BlobDescriptor | undefined> => {
// TODO investigate why mimetype is not set for reuploaded thumbnail (on mediaserver) // TODO investigate why mimetype is not set for reuploaded thumbnail (on mediaserver)
const servers = fileEventData.url.map(extractProtocolAndDomain); const servers = fileEventData.url.map(extractDomain);
// upload selected thumbnail to the same blossom servers as the video // upload selected thumbnail to the same blossom servers as the video
let uploadedThumbnails: BlobDescriptor[] = []; let uploadedThumbnails: BlobDescriptor[] = [];
@ -94,7 +96,11 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
uploadedThumbnails = ( uploadedThumbnails = (
await Promise.all( await Promise.all(
servers.map(s => { servers.map(s => {
if (s && selectedThumbnail) return transferBlob(selectedThumbnail, s, signEventTemplate); if (s && selectedThumbnail) {
console.log(s);
console.log(serverInfo);
return transferBlob(selectedThumbnail, serverInfo[s], signEventTemplate);
}
}) })
) )
).filter(t => t !== undefined) as BlobDescriptor[]; ).filter(t => t !== undefined) as BlobDescriptor[];
@ -211,45 +217,47 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
className="textarea textarea-primary" className="textarea textarea-primary"
placeholder="Caption" placeholder="Caption"
></textarea> ></textarea>
{isAudio && (
<span className="font-bold">Genre</span> <>
<div> <span className="font-bold">Genre</span>
<select <div>
className="select select-bordered select-primary w-full max-w-xs" <select
value={fileEventData.genre} className="select select-bordered select-primary w-full max-w-xs"
onChange={e => setFileEventData(ed => ({ ...ed, genre: e.target.value, subgenre: '' }))} value={fileEventData.genre}
> onChange={e => setFileEventData(ed => ({ ...ed, genre: e.target.value, subgenre: '' }))}
<option disabled>Select a genre</option> >
{Object.keys(allGenres).map(g => ( <option disabled>Select a genre</option>
<option key={g} value={g}> {Object.keys(allGenres).map(g => (
{g} <option key={g} value={g}>
</option> {g}
))} </option>
</select> ))}
<select </select>
className="select select-bordered select-primary w-full max-w-xs mt-2" <select
value={fileEventData.subgenre} className="select select-bordered select-primary w-full max-w-xs mt-2"
disabled={ value={fileEventData.subgenre}
fileEventData.genre == undefined || disabled={
allGenres[fileEventData.genre] == undefined || fileEventData.genre == undefined ||
allGenres[fileEventData.genre].length == 0 allGenres[fileEventData.genre] == undefined ||
} allGenres[fileEventData.genre].length == 0
onChange={e => setFileEventData(ed => ({ ...ed, subgenre: e.target.value }))} }
> onChange={e => setFileEventData(ed => ({ ...ed, subgenre: e.target.value }))}
<option disabled value=""> >
Select a sub genre <option disabled value="">
</option> Select a sub genre
{fileEventData.genre &&
allGenres[fileEventData.genre] &&
allGenres[fileEventData.genre].length > 0 &&
allGenres[fileEventData.genre].map(g => (
<option key={g} value={g}>
{g}
</option> </option>
))} {fileEventData.genre &&
</select> allGenres[fileEventData.genre] &&
</div> allGenres[fileEventData.genre].length > 0 &&
allGenres[fileEventData.genre].map(g => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</>
)}
<span className="font-bold">Tags</span> <span className="font-bold">Tags</span>
<TagInput <TagInput
tags={fileEventData.tags} tags={fileEventData.tags}

View File

@ -18,16 +18,16 @@ export const ResizeOptions: ResizeOptionType[] = [
width: undefined, width: undefined,
height: undefined, height: undefined,
}, },
{
name: 'max. 2048x2048 pixels',
width: 2048,
height: 2048,
},
{ {
name: 'max. 1080x1080 pixels', name: 'max. 1080x1080 pixels',
width: 1080, width: 1080,
height: 1080, height: 1080,
}, },
{
name: 'max. 2048x2048 pixels',
width: 2048,
height: 2048,
},
]; ];
export type TransferStats = { export type TransferStats = {
@ -170,7 +170,7 @@ const UploadFileSelection: React.FC<UploadFileSelectionProps> = ({
value={imageResize} value={imageResize}
> >
{ResizeOptions.map((ro, i) => ( {ResizeOptions.map((ro, i) => (
<option key={ro.name} disabled={i == 0}> <option key={ro.name} value={i} disabled={i == 0}>
{ro.name} {ro.name}
</option> </option>
))} ))}

View File

@ -118,20 +118,22 @@ function Upload() {
try { try {
let newBlob: BlobDescriptor; let newBlob: BlobDescriptor;
const progressHandler = (progressEvent: AxiosProgressEvent) => {
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
};
if (server.type == 'blossom') { if (server.type == 'blossom') {
newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => { newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressHandler);
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
});
} else { } else {
newBlob = await uploadNip96File(server, file, '', signEventTemplate); newBlob = await uploadNip96File(server, file, '', signEventTemplate, progressHandler);
} }
console.log('newBlob', newBlob);
serverTransferred += file.size; serverTransferred += file.size;
setTransfers(ut => ({ setTransfers(ut => ({
...ut, ...ut,

View File

@ -53,10 +53,7 @@ export const uploadBlossomBlob = async (
return res.data; return res.data;
}; };
export const downloadBlossomBlob = async ( export const downloadBlossomBlob = async (url: string, onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void) => {
url: string,
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
) => {
const response = await axios.get(url, { const response = await axios.get(url, {
responseType: 'blob', responseType: 'blob',
onDownloadProgress, onDownloadProgress,
@ -92,4 +89,4 @@ export const mirrordBlossomBlob = async (
} }
); );
return res.data; return res.data;
}; };

View File

@ -1,6 +1,7 @@
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk'; import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { Server } from './useUserServers'; import { Server } from './useUserServers';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import axios, { AxiosProgressEvent } from 'axios';
type MediaTransformation = 'resizing' | 'format_conversion' | 'compression' | 'metadata_stripping'; type MediaTransformation = 'resizing' | 'format_conversion' | 'compression' | 'metadata_stripping';
@ -74,7 +75,6 @@ async function createNip98UploadAuthToken(
], ],
}; };
const signedEvent = await signEventTemplate(authEvent); const signedEvent = await signEventTemplate(authEvent);
console.log(JSON.stringify(signedEvent));
return btoa(JSON.stringify(signedEvent)); return btoa(JSON.stringify(signedEvent));
} }
@ -82,18 +82,20 @@ const getValueByTag = (tags: string[][] | undefined, t: string) => tags && tags.
export async function fetchNip96List( export async function fetchNip96List(
server: Server, server: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent> signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onProgress?: (progressEvent: AxiosProgressEvent) => void
) { ) {
const page = 0; const page = 0;
const count = 100; const count = 100;
const baseUrl = server.nip96?.api_url || server.url; const baseUrl = server.nip96?.api_url || server.url;
const listUrl = `${baseUrl}?page=${page}&count=${count}`; const listUrl = `${baseUrl}?page=${page}&count=${count}`;
const response = await fetch(listUrl, { const response = await axios.get(listUrl, {
headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(listUrl, 'GET', signEventTemplate)}` }, headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(listUrl, 'GET', signEventTemplate)}` },
onDownloadProgress: onProgress,
}); });
const list = (await response.json()) as Nip96ListResponse; const list = response.data as Nip96ListResponse;
return list.files.map( return list.files.map(
file => file =>
@ -132,35 +134,36 @@ The server MUST link the user's pubkey string as the owner of the file so to lat
no_transform can be used to replicate a file to multiple servers for redundancy, clients can use the server list to find alternative servers which might contain the same file. When uploading a file and requesting no_transform clients should check that the hash matches in the response in order to detect if the file was modified. no_transform can be used to replicate a file to multiple servers for redundancy, clients can use the server list to find alternative servers which might contain the same file. When uploading a file and requesting no_transform clients should check that the hash matches in the response in order to detect if the file was modified.
*/ */
export async function uploadNip96File( export async function uploadNip96File(
server: Server, server: Server,
file: File, file: File,
caption: string, caption: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent> signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<BlobDescriptor> { ): Promise<BlobDescriptor> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('caption', caption || ''); // RECOMMENDED TODO ADD formData.append('caption', caption || ''); // RECOMMENDED TODO ADD
//formData.append('expiration', server.expiration || ''); //formData.append('expiration', server.expiration || '');
formData.append('size', file.size.toString()); formData.append('size', file.size.toString());
//formData.append('alt', server.alt || ''); // RECOMMENDED formData.append('alt', caption || ''); // RECOMMENDED
//formData.append('media_type', // avatar / banner //formData.append('media_type', // avatar / banner
formData.append('content_type', file.type || ''); formData.append('content_type', file.type || '');
formData.append('no_transform', 'true'); formData.append('no_transform', 'true'); // we don't use any transform for blossom compatibility
const baseUrl = server.nip96?.api_url || server.url; const baseUrl = server.nip96?.api_url || server.url;
const response = await fetch(baseUrl, { const response = await axios.post(baseUrl, formData, {
method: 'POST',
headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(baseUrl, 'POST', signEventTemplate)}` }, headers: { Authorization: `Nostr ${await createNip98UploadAuthToken(baseUrl, 'POST', signEventTemplate)}` },
body: formData, onUploadProgress: onProgress,
}); });
if (!response.ok) { if (response.status >= 400) {
throw new Error(`Failed to upload file: ${response.statusText}`); throw new Error(`Failed to upload file: ${response.statusText}`);
} }
const result = (await response.json()) as Nip96UploadResult; const result = response.data as Nip96UploadResult;
console.log(result); console.log(result);
const x = getValueByTag(result.nip94_event?.tags, 'x') || getValueByTag(result.nip94_event?.tags, 'ox'); const x = getValueByTag(result.nip94_event?.tags, 'x') || getValueByTag(result.nip94_event?.tags, 'ox');
@ -217,19 +220,18 @@ export async function deleteNip96File(
const auth = await createNip98UploadAuthToken(url, 'DELETE', signEventTemplate); const auth = await createNip98UploadAuthToken(url, 'DELETE', signEventTemplate);
const response = await fetch(url, { const response = await axios.delete(url, {
method: 'DELETE',
headers: { headers: {
...headers, ...headers,
authorization: `Nostr ${auth}`, authorization: `Nostr ${auth}`,
}, },
}); });
if (!response.ok) { if (response.status >= 400) {
throw new Error(`Failed to delete file: ${response.statusText}`); throw new Error(`Failed to delete file: ${response.statusText}`);
} }
const result = await response.json(); const result = response.data;
if (result.status !== 'success') { if (result.status !== 'success') {
throw new Error(`Failed to delete file: ${result.message}`); throw new Error(`Failed to delete file: ${result.message}`);
} }

View File

@ -1,6 +1,8 @@
import { AxiosProgressEvent } from 'axios'; import { AxiosProgressEvent } from 'axios';
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk'; import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { downloadBlossomBlob, mirrordBlossomBlob, uploadBlossomBlob } from './blossom'; import { downloadBlossomBlob, mirrordBlossomBlob, uploadBlossomBlob } from './blossom';
import { Server } from './useUserServers';
import { uploadNip96File } from './nip96';
async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> { async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
const response = await fetch(blobUrl); const response = await fetch(blobUrl);
@ -12,26 +14,36 @@ async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
// TODO support nip96 // TODO support nip96
export const transferBlob = async ( export const transferBlob = async (
sourceUrl: string, sourceUrl: string,
targetServer: string, targetServer: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>, signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void onProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<BlobDescriptor> => { ): Promise<BlobDescriptor> => {
console.log({ sourceUrl, targetServer }); console.log({ sourceUrl, targetServer });
if (sourceUrl.startsWith('blob:')) { if (sourceUrl.startsWith('blob:')) {
const file = await blobUrlToFile(sourceUrl, 'cover.jpg'); const file = await blobUrlToFile(sourceUrl, 'cover.jpg');
return await uploadBlossomBlob(targetServer, file, signEventTemplate, onUploadProgress); if (targetServer.type == 'blossom') {
return await uploadBlossomBlob(targetServer.url, file, signEventTemplate, onProgress);
} else {
return await uploadNip96File(targetServer, file, 'cover.jpg', signEventTemplate, onProgress);
}
} else { } else {
const blob = await mirrordBlossomBlob(targetServer, sourceUrl, signEventTemplate); if (targetServer.type == 'blossom') {
if (blob) return blob; const blob = await mirrordBlossomBlob(targetServer.url, sourceUrl, signEventTemplate);
console.log('Mirror failed. Using download + upload instead.'); if (blob) return blob;
console.log('Mirror failed. Using download + upload instead.');
}
const result = await downloadBlossomBlob(sourceUrl, onUploadProgress); const result = await downloadBlossomBlob(sourceUrl, onProgress);
const fileName = sourceUrl.replace(/.*\//, ''); const fileName = sourceUrl.replace(/.*\//, '');
const file = new File([result.data], fileName, { type: result.type, lastModified: new Date().getTime() }); const file = new File([result.data], fileName, { type: result.type, lastModified: new Date().getTime() });
return await uploadBlossomBlob(targetServer, file, signEventTemplate, onUploadProgress); if (targetServer.type == 'blossom') {
return await uploadBlossomBlob(targetServer.url, file, signEventTemplate, onProgress);
} else {
return await uploadNip96File(targetServer, file, fileName, signEventTemplate, onProgress); // TODO add caption
}
} }
}; };

View File

@ -5,7 +5,6 @@ export default {
extend: {}, extend: {},
}, },
plugins: [require('daisyui')], plugins: [require('daisyui')],
daisyui: { daisyui: {
themes: [ themes: [
{ {
@ -13,7 +12,7 @@ export default {
...require('daisyui/src/theming/themes')['dark'], ...require('daisyui/src/theming/themes')['dark'],
primary: '#be185d', primary: '#be185d',
secondary: '#2563eb', secondary: '#2563eb',
accent: '#fb923c', accent: '#ffffff',
info: '#a5b4fc', info: '#a5b4fc',
success: '#6ee7b7', success: '#6ee7b7',
warning: '#facc15', warning: '#facc15',
@ -25,7 +24,7 @@ export default {
...require('daisyui/src/theming/themes')['cupcake'], ...require('daisyui/src/theming/themes')['cupcake'],
primary: '#be185d', primary: '#be185d',
secondary: '#2563eb', secondary: '#2563eb',
accent: '#fb923c', accent: '#000000',
neutral: '#e0e0e0', neutral: '#e0e0e0',
info: '#a5b4fc', info: '#a5b4fc',
success: '#6ee7b7', success: '#6ee7b7',