Skip to content

Commit

Permalink
Tigris (#2048)
Browse files Browse the repository at this point in the history
* Revert "Chunked upload"

This reverts commit 6d76de7.

* Upload straight to Azure (broken with CORS error)

* Use Tigris instead of Azure
  • Loading branch information
stephenwade authored Feb 22, 2025
1 parent f302817 commit 45f7bf6
Show file tree
Hide file tree
Showing 23 changed files with 4,011 additions and 2,877 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,6 @@ jobs:
- name: Run tests
run: npm run test
env:
AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }}
AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }}
AZURE_STORAGE_WEBSITE_DOMAIN: ${{ secrets.AZURE_STORAGE_WEBSITE_DOMAIN }}
CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
# On macOS (installed via Homebrew), the MySQL root password is empty by default
Expand All @@ -66,6 +63,9 @@ jobs:
DATABASE_URL: mysql://root:@localhost:3306/festival
FIRST_ADMIN_EMAIL_ADDRESS: [email protected]
PORT: '3000'
TIGRIS_ACCESS_KEY_ID: ${{ secrets.TIGRIS_ACCESS_KEY_ID }}
TIGRIS_BUCKET: festival-ci
TIGRIS_SECRET_ACCESS_KEY: ${{ secrets.TIGRIS_SECRET_ACCESS_KEY }}

- name: Upload test results
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ at the same time) without requiring live streaming infrastructure.
- Run `npm install` to install the required packages.
- Run `npm run dev` to serve the application locally.

## Azure Setup
## Tigris Setup

Make sure that your Azure storage account is set to allow
[CORS requests](https://stackoverflow.com/a/41351674).
This is required for the visualizer to work.
- Create a new public bucket in Tigris.
- In the bucket settings, disable directory listing.
- In the bucket settings, add a CORS rule:
- Origins: `*` for development or the URL origin of the deployed app
- Allowed Methods: HEAD, GET, PUT, OPTIONS
- HEAD, GET, and OPTIONS are required for the visualizer
- PUT and OPTIONS are required for file uploads
- Allowed Headers: `content-type`

## Testing

Expand All @@ -34,8 +39,8 @@ Pushing to main triggers a deploy on Railway.

## External Dependencies

- [Azure](https://azure.microsoft.com/en-us/) for file storage
- [Clerk](https://clerk.com/) for authentication
- [DigitalOcean](https://www.digitalocean.com/) for database hosting, DNS, and proxying to Railway
- [Railway](https://railway.app/) for hosting
- [Sentry](https://sentry.io/) for error reporting
- [Tigris](https://www.tigrisdata.com/) for file storage
49 changes: 0 additions & 49 deletions app/azure/blob-client.server.ts

This file was deleted.

148 changes: 81 additions & 67 deletions app/components/admin/upload/AudioFileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,48 @@ import type { FC } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useControlField, useField } from 'remix-validated-form';

import {
UPLOAD_AUDIO_CONTENT_TYPE_KEY,
UPLOAD_AUDIO_NAME_KEY,
} from '~/forms/upload-audio';
import { useSse } from '~/hooks/useSse';
import type { loader as audioUploadLoader } from '~/routes/admin.audio-upload.$id';
import type { action as newAudioUploadAction } from '~/routes/admin.audio-upload.new';
import type { AudioFileProcessingEvent } from '~/sse.server/audio-file-events';

import type { useUploadAudioFile } from './useUploadAudioFile';
import { xhrPromise } from './xhrPromise';

type UploadResponse = SerializeFrom<typeof newAudioUploadAction>;

type SerializeFrom<T> = ReturnType<typeof useLoaderData<T>>;

function displayConversionStatus(
status: Exclude<AudioFileProcessingEvent['conversionStatus'], 'DONE'>,
) {
switch (status) {
case 'USER_UPLOAD':
return 'Uploading…';
case 'CHECKING':
return 'Checking…';
case 'CONVERTING':
return 'Converting…';
case 'UPLOADING':
return 'Uploading…';
case 'RE_UPLOAD':
return 'Converting…';
case 'ERROR':
return 'Error';
}
}

interface AudioFileUploadProps {
name: string;
uploadAudioFile: ReturnType<typeof useUploadAudioFile>;
isUploading: boolean;
setIsUploading: (isUploading: boolean) => void;
}

export const AudioFileUpload: FC<AudioFileUploadProps> = ({
name,
uploadAudioFile,
isUploading,
setIsUploading,
}) => {
const { getInputProps } = useField(name);
const [fileId, setFileId] = useControlField<string | undefined>(name);
Expand Down Expand Up @@ -66,31 +77,50 @@ export const AudioFileUpload: FC<AudioFileUploadProps> = ({
const file = fileState ?? fetcher.data;

const [fileName, setFileName] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);

const { start, pause, resume, abort, state } = uploadAudioFile;
const [uploadProgress, setUploadProgress] = useState(0);

useEffect(() => {
if (state?.status === 'done') {
setFileState(state.file);

// Wait a bit to make sure the fetcher is not triggered before the
// file upload state is updated.
setTimeout(() => {
setFileId(state.file.id);
}, 100);
}
}, [setFileId, state]);

const onUploadClick = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const onUploadClick = async () => {
const fileInput = fileInputRef.current;
if (!fileInput?.files?.length) return;

const file = fileInput.files[0]!;
fileInput.value = '';
setIsUploading(true);
setFileName(file.name);

start(file);
setUploadProgress(0);

const form = new FormData();
form.append(UPLOAD_AUDIO_NAME_KEY, file.name);
form.append(UPLOAD_AUDIO_CONTENT_TYPE_KEY, file.type);

const newFileResponse = await fetch('/admin/audio-upload/new', {
method: 'POST',
body: form,
});
const { file: newFile, uploadUrl } =
(await newFileResponse.json()) as UploadResponse;
setFileState(newFile);
// Wait a bit to make sure the fetcher is not triggered before the
// file upload state is updated.
setTimeout(() => {
setFileId(newFile.id);
}, 100);

xhrPromise(file, {
url: uploadUrl,
onProgress: setUploadProgress,
errorOnBadStatus: true,
})
.then(() => {
setIsUploading(false);
void fetch(`/admin/audio-upload/${newFile.id}/process`, {
method: 'POST',
});
})
.catch((error: unknown) => {
console.error(`Audio file upload ${name} failed.`, error);
});
};

const onRemoveFileClick = () => {
Expand All @@ -107,55 +137,39 @@ export const AudioFileUpload: FC<AudioFileUploadProps> = ({
})}
/>
<p>
{state === undefined ? (
{isUploading ? (
<>
<input type="file" ref={fileInputRef} accept="audio/*" />{' '}
<button type="button" onClick={onUploadClick}>
Upload
</button>
</>
) : state.status === 'in progress' ? (
<>
Uploading… <progress value={state.progress} /> {fileName}{' '}
<button type="button" onClick={pause}>
Pause
</button>{' '}
<button type="button" onClick={abort}>
Cancel
</button>
</>
) : state.status === 'paused' ? (
<>
Paused <progress value={state.progress} /> {fileName}{' '}
<button type="button" onClick={resume}>
Resume
</button>{' '}
<button type="button" onClick={abort}>
Cancel
</button>
Uploading… <progress value={uploadProgress} /> {fileName}
</>
) : state.status === 'error' ? (
<>Error while uploading file</>
) : file ? (
file.conversionStatus === 'DONE' ? (
<>
Duration: {file.duration}{' '}
<button type="button" onClick={onRemoveFileClick}>
Remove file
</button>
</>
) : file.errorMessage ? (
<>Error while converting audio file: {file.errorMessage}</>
) : fileId ? (
file ? (
file.conversionStatus === 'DONE' ? (
<>
Duration: {file.duration}{' '}
<button type="button" onClick={onRemoveFileClick}>
Remove file
</button>
</>
) : file.errorMessage ? (
<>Error while converting audio file: {file.errorMessage}</>
) : (
<>
{displayConversionStatus(file.conversionStatus)}{' '}
{file.conversionProgress === null ? null : (
<progress value={file.conversionProgress} />
)}
</>
)
) : (
<>
{displayConversionStatus(file.conversionStatus)}{' '}
{file.conversionProgress === null ? null : (
<progress value={file.conversionProgress} />
)}
</>
'Loading…'
)
) : (
'Loading…'
<>
<input type="file" ref={fileInputRef} accept="audio/*" />{' '}
<button type="button" onClick={() => void onUploadClick()}>
Upload
</button>
</>
)}
</p>
</>
Expand Down
43 changes: 26 additions & 17 deletions app/components/admin/upload/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useControlField, useField } from 'remix-validated-form';

import { UPLOAD_FILE_FORM_KEY } from '~/forms/upload-file';
import {
UPLOAD_FILE_CONTENT_TYPE_KEY,
UPLOAD_FILE_NAME_KEY,
} from '~/forms/upload-file';
import type { loader as fileUploadLoader } from '~/routes/admin.file-upload.$id';
import type { action as newFileUploadAction } from '~/routes/admin.file-upload.new';

Expand Down Expand Up @@ -47,7 +50,7 @@ export const FileUpload: FC<FileUploadProps> = ({
const [uploadProgress, setUploadProgress] = useState(0);

const fileInputRef = useRef<HTMLInputElement>(null);
const onUploadClick = () => {
const onUploadClick = async () => {
const fileInput = fileInputRef.current;
if (!fileInput?.files?.length) return;

Expand All @@ -58,23 +61,29 @@ export const FileUpload: FC<FileUploadProps> = ({
setUploadProgress(0);

const form = new FormData();
form.append(UPLOAD_FILE_FORM_KEY, file);
xhrPromise(form, {
url: '/admin/file-upload/new',
form.append(UPLOAD_FILE_NAME_KEY, file.name);
form.append(UPLOAD_FILE_CONTENT_TYPE_KEY, file.type);

const newFileResponse = await fetch('/admin/file-upload/new', {
method: 'POST',
body: form,
});
const { file: newFile, uploadUrl } =
(await newFileResponse.json()) as UploadResponse;
setFileState(newFile);
// Wait a bit to make sure the fetcher is not triggered before the
// file upload state is updated.
setTimeout(() => {
setFileId(newFile.id);
}, 100);

xhrPromise(file, {
url: uploadUrl,
onProgress: setUploadProgress,
errorOnBadStatus: true,
})
.then((response) => {
const file = JSON.parse(response.responseText) as UploadResponse;

setFileState(file);

// Wait a bit to make sure the fetcher is not triggered before the
// file upload state is updated.
setTimeout(() => {
setFileId(file.id);
setIsUploading(false);
}, 100);
.then(() => {
setIsUploading(false);
})
.catch((error: unknown) => {
console.error(`File upload ${name} failed.`, error);
Expand Down Expand Up @@ -113,7 +122,7 @@ export const FileUpload: FC<FileUploadProps> = ({
) : (
<>
<input type="file" ref={fileInputRef} accept="image/*" />{' '}
<button type="button" onClick={onUploadClick}>
<button type="button" onClick={() => void onUploadClick()}>
Upload
</button>
</>
Expand Down
Loading

0 comments on commit 45f7bf6

Please sign in to comment.