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>
{blob.data.id3 && (
<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.album && (
<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} />
</div>
<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>
</>

View File

@ -8,6 +8,7 @@ import { transferBlob } from '../../utils/transfer';
import { useNDK } from '../../utils/ndk';
import TagInput from '../TagInput';
import { allGenres } from '../../utils/genres';
import { useServerInfo } from '../../utils/useServerInfo';
export type FileEventData = {
originalFile: File;
@ -35,6 +36,7 @@ export type FileEventData = {
const FileEventEditor = ({ data }: { data: FileEventData }) => {
const { signEventTemplate } = useNDK();
const { serverInfo } = useServerInfo();
const [fileEventData, setFileEventData] = useState(data);
const [selectedThumbnail, setSelectedThumbnail] = useState<string | undefined>();
@ -78,15 +80,15 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
}
}, [fileEventData]);
function extractProtocolAndDomain(url: string): string | null {
const regex = /^(https?:\/\/[^/]+)/;
function extractDomain(url: string): string | null {
const regex = /^(https?:\/\/)([^/]+)/;
const match = url.match(regex);
return match ? match[0] : null;
return match ? match[2]?.toLocaleLowerCase() : null;
}
const publishSelectedThumbnailToAllOwnServers = async (): Promise<BlobDescriptor | undefined> => {
// 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
let uploadedThumbnails: BlobDescriptor[] = [];
@ -94,7 +96,11 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
uploadedThumbnails = (
await Promise.all(
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[];
@ -211,45 +217,47 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
className="textarea textarea-primary"
placeholder="Caption"
></textarea>
<span className="font-bold">Genre</span>
<div>
<select
className="select select-bordered select-primary w-full max-w-xs"
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 key={g} value={g}>
{g}
</option>
))}
</select>
<select
className="select select-bordered select-primary w-full max-w-xs mt-2"
value={fileEventData.subgenre}
disabled={
fileEventData.genre == undefined ||
allGenres[fileEventData.genre] == undefined ||
allGenres[fileEventData.genre].length == 0
}
onChange={e => setFileEventData(ed => ({ ...ed, subgenre: e.target.value }))}
>
<option disabled value="">
Select a sub genre
</option>
{fileEventData.genre &&
allGenres[fileEventData.genre] &&
allGenres[fileEventData.genre].length > 0 &&
allGenres[fileEventData.genre].map(g => (
<option key={g} value={g}>
{g}
{isAudio && (
<>
<span className="font-bold">Genre</span>
<div>
<select
className="select select-bordered select-primary w-full max-w-xs"
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 key={g} value={g}>
{g}
</option>
))}
</select>
<select
className="select select-bordered select-primary w-full max-w-xs mt-2"
value={fileEventData.subgenre}
disabled={
fileEventData.genre == undefined ||
allGenres[fileEventData.genre] == undefined ||
allGenres[fileEventData.genre].length == 0
}
onChange={e => setFileEventData(ed => ({ ...ed, subgenre: e.target.value }))}
>
<option disabled value="">
Select a sub genre
</option>
))}
</select>
</div>
{fileEventData.genre &&
allGenres[fileEventData.genre] &&
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>
<TagInput
tags={fileEventData.tags}

View File

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

View File

@ -118,20 +118,22 @@ function Upload() {
try {
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') {
newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => {
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
});
newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressHandler);
} else {
newBlob = await uploadNip96File(server, file, '', signEventTemplate);
newBlob = await uploadNip96File(server, file, '', signEventTemplate, progressHandler);
}
console.log('newBlob', newBlob);
serverTransferred += file.size;
setTransfers(ut => ({
...ut,

View File

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

View File

@ -1,6 +1,7 @@
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { Server } from './useUserServers';
import dayjs from 'dayjs';
import axios, { AxiosProgressEvent } from 'axios';
type MediaTransformation = 'resizing' | 'format_conversion' | 'compression' | 'metadata_stripping';
@ -74,7 +75,6 @@ async function createNip98UploadAuthToken(
],
};
const signedEvent = await signEventTemplate(authEvent);
console.log(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(
server: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onProgress?: (progressEvent: AxiosProgressEvent) => void
) {
const page = 0;
const count = 100;
const baseUrl = server.nip96?.api_url || server.url;
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)}` },
onDownloadProgress: onProgress,
});
const list = (await response.json()) as Nip96ListResponse;
const list = response.data as Nip96ListResponse;
return list.files.map(
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.
*/
export async function uploadNip96File(
server: Server,
file: File,
caption: string,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<BlobDescriptor> {
const formData = new FormData();
formData.append('file', file);
formData.append('caption', caption || ''); // RECOMMENDED TODO ADD
//formData.append('expiration', server.expiration || '');
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('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 response = await fetch(baseUrl, {
method: 'POST',
const response = await axios.post(baseUrl, formData, {
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}`);
}
const result = (await response.json()) as Nip96UploadResult;
const result = response.data as Nip96UploadResult;
console.log(result);
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 response = await fetch(url, {
method: 'DELETE',
const response = await axios.delete(url, {
headers: {
...headers,
authorization: `Nostr ${auth}`,
},
});
if (!response.ok) {
if (response.status >= 400) {
throw new Error(`Failed to delete file: ${response.statusText}`);
}
const result = await response.json();
const result = response.data;
if (result.status !== 'success') {
throw new Error(`Failed to delete file: ${result.message}`);
}

View File

@ -1,6 +1,8 @@
import { AxiosProgressEvent } from 'axios';
import { BlobDescriptor, EventTemplate, SignedEvent } from 'blossom-client-sdk';
import { downloadBlossomBlob, mirrordBlossomBlob, uploadBlossomBlob } from './blossom';
import { Server } from './useUserServers';
import { uploadNip96File } from './nip96';
async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
const response = await fetch(blobUrl);
@ -12,26 +14,36 @@ async function blobUrlToFile(blobUrl: string, fileName: string): Promise<File> {
// TODO support nip96
export const transferBlob = async (
sourceUrl: string,
targetServer: string,
targetServer: Server,
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
onProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<BlobDescriptor> => {
console.log({ sourceUrl, targetServer });
if (sourceUrl.startsWith('blob:')) {
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 {
const blob = await mirrordBlossomBlob(targetServer, sourceUrl, signEventTemplate);
if (blob) return blob;
console.log('Mirror failed. Using download + upload instead.');
if (targetServer.type == 'blossom') {
const blob = await mirrordBlossomBlob(targetServer.url, sourceUrl, signEventTemplate);
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 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: {},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
{
@ -13,7 +12,7 @@ export default {
...require('daisyui/src/theming/themes')['dark'],
primary: '#be185d',
secondary: '#2563eb',
accent: '#fb923c',
accent: '#ffffff',
info: '#a5b4fc',
success: '#6ee7b7',
warning: '#facc15',
@ -25,7 +24,7 @@ export default {
...require('daisyui/src/theming/themes')['cupcake'],
primary: '#be185d',
secondary: '#2563eb',
accent: '#fb923c',
accent: '#000000',
neutral: '#e0e0e0',
info: '#a5b4fc',
success: '#6ee7b7',