Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/deepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
parseAvailableBrowserModes,
parseBrowserViewUrl,
readRequestedBrowserMode,
readRequestedOutputFormat,
readStoredBrowserMode,
readStoredOutputFormat,
shouldAutoStartDeepLink,
shouldUseEmbeddedLayout,
} from '../webui/src/components/deepLink';
Expand Down Expand Up @@ -38,6 +40,23 @@ describe('embedded layout query parameter', () => {
});
});

describe('output-format preference hydration', () => {
test('reads a valid output format from a userscript deep link', () => {
expect(readRequestedOutputFormat('?outputFormat=flac')).toBe('flac');
expect(readRequestedOutputFormat('?outputFormat=invalid')).toBeNull();
});

test('reads stored formats through the same helper used by the UI', () => {
const storage = { getItem: () => 'original' };
expect(readStoredOutputFormat(storage, 'output-format')).toBe('original');
});

test('ignores invalid stored formats', () => {
const storage = { getItem: () => 'mp3' };
expect(readStoredOutputFormat(storage, 'output-format')).toBeNull();
});
});

describe('browser-mode preference hydration', () => {
test('reads a valid browser mode from a userscript deep link', () => {
expect(readRequestedBrowserMode('?browserMode=xvfb')).toBe('xvfb');
Expand Down
60 changes: 44 additions & 16 deletions src/mypresskit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ export class MypresskitDownloader {
const reader = response.body.getReader();
let received = 0;
let completed = false;
let lastProgressEmit = 0;
const pBar = new SingleBar(
{
format:
Expand All @@ -664,6 +665,43 @@ export class MypresskitDownloader {
},
);

const emitDownloadProgress = (force = false) => {
const now = Date.now();
if (
!force &&
now - lastProgressEmit < 250 &&
!(totalBytes > 0 && received >= totalBytes)
) {
return;
}
lastProgressEmit = now;
const currentMb = Number((received / 1024 / 1024).toFixed(2));
if (totalBytes > 0) {
if (pBar.isActive) {
pBar.update(received, {
total_mb: Number((totalBytes / 1024 / 1024).toFixed(2)),
current_mb: currentMb,
});
} else {
pBar.start(totalBytes, received, { prefix: 'Downloading' });
}
const percent = Math.min(100, (received / totalBytes) * 100);
this.emitProgress(
'downloading',
`Downloading... ${currentMb.toFixed(1)} / ${(totalBytes / 1024 / 1024).toFixed(1)} MB`,
percent,
{ downloadBytes: received, totalBytes },
);
return;
}
this.emitProgress(
'downloading',
`Downloading... ${currentMb.toFixed(1)} MB`,
0,
{ downloadBytes: received },
);
};

try {
while (true) {
const { done, value } = await reader.read();
Expand All @@ -676,25 +714,15 @@ export class MypresskitDownloader {
);
}
writer.write(value);
if (totalBytes > 0) {
if (pBar.isActive) {
pBar.update(received, {
total_mb: Number((totalBytes / 1024 / 1024).toFixed(2)),
current_mb: Number((received / 1024 / 1024).toFixed(2)),
});
} else {
pBar.start(totalBytes, received, { prefix: 'Downloading' });
}
this.emitProgress(
'downloading',
`Downloading... ${(received / 1024 / 1024).toFixed(1)} / ${(totalBytes / 1024 / 1024).toFixed(1)} MB`,
0,
{ downloadBytes: received, totalBytes },
);
}
emitDownloadProgress();
}
await writer.end();
completed = true;
emitDownloadProgress(true);
this.emitProgress('downloading', 'Download complete', 100, {
downloadBytes: received,
...(totalBytes > 0 ? { totalBytes } : {}),
});
} finally {
pBar.stop();
if (!completed) {
Expand Down
11 changes: 11 additions & 0 deletions src/safeOutboundUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ export async function safeFetch(
const bytes =
typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
controller.enqueue(new Uint8Array(bytes));
// Pause the socket when the consumer is behind so progress
// callbacks can run against live download progress.
if (
controller.desiredSize !== null &&
controller.desiredSize <= 0
) {
res.pause();
}
});
res.on('end', () => {
try {
Expand All @@ -173,6 +181,9 @@ export async function safeFetch(
});
res.on('error', (err) => controller.error(err));
},
pull() {
res.resume();
},
cancel() {
res.destroy();
},
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export interface Metadata {

export type OutputFormat = 'original' | 'mp3-320' | 'flac';

export function isOutputFormat(value: unknown): value is OutputFormat {
return value === 'original' || value === 'mp3-320' || value === 'flac';
}

// Job system types for Web UI
export type JobStage =
| 'pending'
Expand Down
92 changes: 60 additions & 32 deletions webui/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Toaster, toast } from 'sonner';
import type { BrowserMode } from '../../../src/types';
import type { BrowserMode, OutputFormat } from '../../../src/types';
import './App.css';
import { resolveApiBase } from './apiBase';
import {
parseAvailableBrowserModes,
parseBrowserViewUrl,
readRequestedBrowserMode,
readRequestedOutputFormat,
readStoredBrowserMode,
readStoredOutputFormat,
shouldAutoStartDeepLink,
shouldUseEmbeddedLayout,
} from './deepLink';
import { RemoteBrowserPanel } from './RemoteBrowserPanel';
import { REMOTE_POINTER_RELEASE_EVENT } from './remoteBrowser';

type Step = 'url' | 'gate' | 'download' | 'metadata' | 'complete';
type OutputFormat = 'original' | 'mp3-320' | 'flac';

interface Metadata {
title?: string;
Expand Down Expand Up @@ -89,6 +90,7 @@ const API_BASE = resolveApiBase(
import.meta.env.PUBLIC_API_BASE_URL,
);
const BROWSER_MODE_STORAGE_KEY = 'sc-gate-dl-browser-mode';
const OUTPUT_FORMAT_STORAGE_KEY = 'sc-gate-dl-output-format';
const BROWSER_MODE_LABELS: Record<BrowserMode, string> = {
headless: 'Headless',
xvfb: 'Invisible headed (Xvfb)',
Expand Down Expand Up @@ -351,11 +353,16 @@ export default function App() {
setBrowserViewUrl(viewUrl);
const requestedMode = readRequestedBrowserMode(window.location.search);
let storedMode: BrowserMode | null = null;
let storedFormat: ReturnType<typeof readStoredOutputFormat> = null;
try {
storedMode = readStoredBrowserMode(
localStorage,
BROWSER_MODE_STORAGE_KEY,
);
storedFormat = readStoredOutputFormat(
localStorage,
OUTPUT_FORMAT_STORAGE_KEY,
);
} catch {
// Storage may be blocked when the Web UI is embedded cross-origin.
}
Expand All @@ -375,6 +382,18 @@ export default function App() {
}
}
}
const requestedFormat = readRequestedOutputFormat(window.location.search);
const preferredFormat = requestedFormat ?? storedFormat;
if (preferredFormat) {
setOutputFormat(preferredFormat);
if (requestedFormat === preferredFormat) {
try {
localStorage.setItem(OUTPUT_FORMAT_STORAGE_KEY, preferredFormat);
} catch {
// The requested format still applies for this session.
}
}
}
setBrowserModeHydrated(true);
};

Expand All @@ -396,6 +415,15 @@ export default function App() {
// Keep the in-memory selection when persistent storage is unavailable.
}
};

const updateOutputFormat = (format: OutputFormat) => {
setOutputFormat(format);
try {
localStorage.setItem(OUTPUT_FORMAT_STORAGE_KEY, format);
} catch {
// Keep the in-memory selection when persistent storage is unavailable.
}
};
const [customArtwork, setCustomArtwork] = useState<File | null>(null);
const [nameAsArtistTitle, setNameAsArtistTitle] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -831,16 +859,9 @@ export default function App() {
// keep queryUrl as-is
}

const formatParam = params.get('outputFormat');
const format: OutputFormat | undefined =
formatParam === 'original' ||
formatParam === 'mp3-320' ||
formatParam === 'flac'
? formatParam
: undefined;
if (format) {
setOutputFormat(format);
}
// Format is already hydrated from the query/localStorage before this runs.
const format =
readRequestedOutputFormat(window.location.search) ?? undefined;
setSoundcloudUrl(queryUrl);
void createJob(queryUrl, format);
}, [browserModeHydrated, createJob]);
Expand Down Expand Up @@ -1304,7 +1325,7 @@ export default function App() {
id="output-format"
value={outputFormat}
onChange={(e) =>
setOutputFormat(e.target.value as OutputFormat)
updateOutputFormat(e.target.value as OutputFormat)
}
disabled={isLoading}
>
Expand Down Expand Up @@ -1485,9 +1506,10 @@ export default function App() {
<>
<div className="progress-stage">
<span className="stage-label">
{job.progress?.downloadBytes !== undefined
? 'Downloading...'
: job.progress?.message || 'Initializing...'}
{job.progress?.message ||
(job.progress?.downloadBytes !== undefined
? 'Downloading...'
: 'Initializing...')}
</span>
{job.progress?.currentGate && (
<span className="gate-badge">
Expand All @@ -1508,24 +1530,30 @@ export default function App() {
{step === 'download' && (
<>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${job.progress?.percent || 0}%` }}
/>
{typeof job.progress?.totalBytes === 'number' ? (
<div
className="progress-fill"
style={{ width: `${job.progress?.percent || 0}%` }}
/>
) : (
<div className="progress-fill progress-fill-indeterminate" />
)}
</div>
<div className="progress-stats">
<span>{formatPercent(job.progress?.percent)}%</span>
{job.progress?.downloadBytes !== undefined &&
job.progress?.totalBytes !== undefined && (
<span>
{(job.progress.downloadBytes / 1024 / 1024).toFixed(
1,
)}{' '}
/{' '}
{(job.progress.totalBytes / 1024 / 1024).toFixed(1)}{' '}
MB
</span>
)}
{typeof job.progress?.totalBytes === 'number' ? (
<span>{formatPercent(job.progress?.percent)}%</span>
) : null}
{job.progress?.downloadBytes !== undefined && (
<span>
{(job.progress.downloadBytes / 1024 / 1024).toFixed(
1,
)}
{typeof job.progress.totalBytes === 'number'
? ` / ${(job.progress.totalBytes / 1024 / 1024).toFixed(1)}`
: ''}{' '}
MB
</span>
)}
</div>
</>
)}
Expand Down
20 changes: 19 additions & 1 deletion webui/src/components/deepLink.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type BrowserMode, isBrowserMode } from '../../../src/types';
import {
type BrowserMode,
isBrowserMode,
isOutputFormat,
type OutputFormat,
} from '../../../src/types';

export function readStoredBrowserMode(
storage: Pick<Storage, 'getItem'>,
Expand All @@ -13,6 +18,19 @@ export function readRequestedBrowserMode(search: string): BrowserMode | null {
return isBrowserMode(mode) ? mode : null;
}

export function readStoredOutputFormat(
storage: Pick<Storage, 'getItem'>,
key: string,
): OutputFormat | null {
const storedFormat = storage.getItem(key);
return isOutputFormat(storedFormat) ? storedFormat : null;
}

export function readRequestedOutputFormat(search: string): OutputFormat | null {
const format = new URLSearchParams(search).get('outputFormat');
return isOutputFormat(format) ? format : null;
}

export function shouldAutoStartDeepLink(
browserModeHydrated: boolean,
alreadyStarted: boolean,
Expand Down