mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 18:33:39 +00:00
fix: address post-audit regressions across queue, db, and ui
- Preserved extension-captured cookies through the Add modal, with a clean fallback when captured cookies break metadata fetching. - Prevented batched extension captures from losing URLs or reusing stale cookie/header contexts. - Fixed pause/resume and enqueue generation races, including cancellation during queue reservation and replay after task removal. - Made startup database initialization safe under React StrictMode. - Serialized keyring operations and corrected Linux legacy migration/deletion behavior. - Restored `Downloading` state after yt-dlp retries. - Replaced hardcoded media heights with dynamically detected formats, including nonstandard qualities such as 576p and 2880p.
This commit is contained in:
+18
-3
@@ -37,6 +37,20 @@ const waitForSettingsHydration = (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
let downloadStateInitialization: Promise<void> | null = null;
|
||||
const initializeDownloadState = (): Promise<void> => {
|
||||
if (!downloadStateInitialization) {
|
||||
downloadStateInitialization = (async () => {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
})().catch(error => {
|
||||
downloadStateInitialization = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return downloadStateInitialization;
|
||||
};
|
||||
|
||||
const getScheduledQueueIds = () => {
|
||||
const downloadState = useDownloadStore.getState();
|
||||
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
||||
@@ -227,10 +241,11 @@ function App() {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
try {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
if (active) setCoreReady(true);
|
||||
await initializeDownloadState();
|
||||
if (!active) return;
|
||||
setCoreReady(true);
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
canSubmitMetadataRows,
|
||||
appendRequestUrlsAfterVersion,
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaFormatSelectorForRow,
|
||||
metadataSummaryMessage,
|
||||
@@ -65,7 +66,6 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion,
|
||||
pendingAddLatestUrls,
|
||||
toggleAddModal,
|
||||
addDownload,
|
||||
queues
|
||||
@@ -120,8 +120,9 @@ export const AddDownloadsModal = () => {
|
||||
if (context) return extensionHeaders(context).trim();
|
||||
return hasExtensionRequestContext ? '' : headers.trim();
|
||||
};
|
||||
const cookiesForRow = (sourceUrl: string) => {
|
||||
const cookiesForRow = (sourceUrl: string, omitRequestCookies = false) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
if (omitRequestCookies) return '';
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context) return context.cookies.trim();
|
||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||
@@ -189,14 +190,14 @@ export const AddDownloadsModal = () => {
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen || !modalSessionRef.current
|
||||
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
||||
const observedVersion = observedRequestVersionRef.current;
|
||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||
const additions = pendingAddLatestUrls
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(Boolean);
|
||||
if (additions.length === 0) return;
|
||||
setUrls(current => current.trim() ? `${current.trim()}\n${additions.join('\n')}` : additions.join('\n'));
|
||||
}, [isAddModalOpen, pendingAddRequestVersion, pendingAddLatestUrls]);
|
||||
setUrls(current => appendRequestUrlsAfterVersion(
|
||||
current,
|
||||
pendingAddRequestContexts,
|
||||
observedVersion
|
||||
));
|
||||
}, [isAddModalOpen, pendingAddRequestContexts, pendingAddRequestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQueueMenuOpen) return;
|
||||
@@ -302,7 +303,25 @@ export const AddDownloadsModal = () => {
|
||||
cookies: rowCookies || null,
|
||||
proxy
|
||||
};
|
||||
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
let requestCookiesOmitted = false;
|
||||
let mediaData;
|
||||
try {
|
||||
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
} catch (error) {
|
||||
const capturedCookies = requestContextForUrl(row.sourceUrl)?.cookies.trim();
|
||||
if (!rowCookies || !capturedCookies || cookiesManuallyEditedRef.current) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
'Media metadata rejected the captured Cookie header; retrying without request cookies',
|
||||
error
|
||||
);
|
||||
mediaData = await fetchMediaMetadataDeduped({
|
||||
...mediaMetadataArgs,
|
||||
cookies: null
|
||||
});
|
||||
requestCookiesOmitted = true;
|
||||
}
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
const mappedFormats = mediaData.formats.map(f => {
|
||||
const quality = f.resolution || 'Video';
|
||||
@@ -334,6 +353,7 @@ export const AddDownloadsModal = () => {
|
||||
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
||||
sizeBytes: mappedFormats[0].bytes || undefined,
|
||||
status: 'ready',
|
||||
requestCookiesOmitted,
|
||||
formats: mappedFormats,
|
||||
selectedFormat: 0
|
||||
})
|
||||
@@ -709,7 +729,7 @@ export const AddDownloadsModal = () => {
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookiesForRow(item.sourceUrl) || undefined,
|
||||
cookies: cookiesForRow(item.sourceUrl, item.requestCookiesOmitted) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination
|
||||
? finalLocation
|
||||
@@ -1128,6 +1148,11 @@ export const AddDownloadsModal = () => {
|
||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||
aria-label="Cookies"
|
||||
/>
|
||||
{!cookiesManuallyEditedRef.current && parsedItems.some(item => item.requestCookiesOmitted) && (
|
||||
<p className="mt-1 text-[10px] text-amber-400">
|
||||
Media metadata only worked without the captured cookies, so they will be omitted for affected rows. Edit this field to force a manual value.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
||||
|
||||
@@ -90,7 +90,6 @@ describe('useDownloadStore', () => {
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
pendingAddLatestUrls: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,6 +314,15 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
const pause = useDownloadStore.getState().pauseDownload('paused');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'paused' })
|
||||
);
|
||||
});
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'pause_download')
|
||||
).toBe(false);
|
||||
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
|
||||
|
||||
await expect(pause).resolves.toBeUndefined();
|
||||
@@ -784,21 +792,22 @@ describe('useDownloadStore', () => {
|
||||
'https://first.example/file.zip\nhttps://second.example/file.zip'
|
||||
);
|
||||
expect(state.pendingAddRequestVersion).toBe(2);
|
||||
expect(state.pendingAddLatestUrls).toBe('https://second.example/file.zip');
|
||||
expect(state.pendingAddRequestContexts).toEqual({
|
||||
'https://first.example/file.zip': {
|
||||
version: 1,
|
||||
referer: 'https://first.example/page',
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session'
|
||||
cookies: 'first=session',
|
||||
media: false
|
||||
},
|
||||
'https://second.example/file.zip': {
|
||||
version: 2,
|
||||
referer: 'https://second.example/page',
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session'
|
||||
cookies: 'second=session',
|
||||
media: false
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -818,7 +827,38 @@ describe('useDownloadStore', () => {
|
||||
expect(state.isAddModalOpen).toBe(true);
|
||||
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
||||
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
|
||||
expect(state.pendingAddCookies).toBe('');
|
||||
expect(state.pendingAddCookies).toBe('session=secret');
|
||||
});
|
||||
|
||||
it('clears stale request context when the same URL is captured without it later', async () => {
|
||||
const url = 'https://example.com/file.zip';
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: [url],
|
||||
referer: 'https://example.com/private',
|
||||
silent: true,
|
||||
filename: 'private.zip',
|
||||
headers: 'Authorization: secret',
|
||||
cookies: 'session=secret',
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: [url],
|
||||
referer: null,
|
||||
silent: true,
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
||||
version: 2,
|
||||
referer: '',
|
||||
filename: '',
|
||||
headers: '',
|
||||
cookies: '',
|
||||
media: false
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
|
||||
|
||||
@@ -311,6 +311,7 @@ export type PendingAddRequestContext = {
|
||||
filename: string;
|
||||
headers: string;
|
||||
cookies: string;
|
||||
media: boolean;
|
||||
};
|
||||
|
||||
export type DeleteModalState = {
|
||||
@@ -338,7 +339,6 @@ interface DownloadState {
|
||||
pendingAddMediaUrls: string[];
|
||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||
pendingAddRequestVersion: number;
|
||||
pendingAddLatestUrls: string;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (
|
||||
@@ -475,7 +475,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
pendingAddLatestUrls: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
deleteModalState: { isOpen: false },
|
||||
openDeleteModal: (downloadIds) => set({
|
||||
@@ -493,8 +492,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddLatestUrls: ''
|
||||
pendingAddRequestContexts: {}
|
||||
}),
|
||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
@@ -512,28 +510,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const cleanHeaders = headers?.trim() || '';
|
||||
const cleanCookies = cookies?.trim() || '';
|
||||
const requestVersion = state.pendingAddRequestVersion + 1;
|
||||
const hasRequestContext = Boolean(cleanReferer || cleanFilename || cleanHeaders || cleanCookies);
|
||||
const pendingAddRequestContexts = isAppending
|
||||
? { ...state.pendingAddRequestContexts }
|
||||
: {};
|
||||
if (hasRequestContext) {
|
||||
for (const rawUrl of urls.split('\n')) {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl) continue;
|
||||
let key = trimmedUrl;
|
||||
try {
|
||||
key = new URL(trimmedUrl).href;
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
}
|
||||
pendingAddRequestContexts[key] = {
|
||||
version: requestVersion,
|
||||
referer: cleanReferer,
|
||||
filename: cleanFilename,
|
||||
headers: cleanHeaders,
|
||||
cookies: cleanCookies
|
||||
};
|
||||
// Every handoff gets a versioned row context, including an intentionally
|
||||
// empty one. Otherwise a later request for the same URL cannot clear stale
|
||||
// cookies/headers from an earlier capture, and batched React renders can
|
||||
// lose all but the most recent appended URL.
|
||||
for (const rawUrl of urls.split('\n')) {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl) continue;
|
||||
let key = trimmedUrl;
|
||||
try {
|
||||
key = new URL(trimmedUrl).href;
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
}
|
||||
pendingAddRequestContexts[key] = {
|
||||
version: requestVersion,
|
||||
referer: cleanReferer,
|
||||
filename: cleanFilename,
|
||||
headers: cleanHeaders,
|
||||
cookies: cleanCookies,
|
||||
media
|
||||
};
|
||||
}
|
||||
return {
|
||||
isAddModalOpen: true,
|
||||
@@ -544,25 +544,19 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddCookies: cleanCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion: requestVersion,
|
||||
pendingAddLatestUrls: urls
|
||||
pendingAddRequestVersion: requestVersion
|
||||
};
|
||||
}),
|
||||
handleExtensionDownload: async (request) => {
|
||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||
if (urls.length === 0) return;
|
||||
|
||||
// Explicit media uses yt-dlp and its configured browser-cookie source.
|
||||
// Passing Firefox's complete page Cookie header can exceed YouTube's
|
||||
// request-header limit; ordinary captured file downloads keep it.
|
||||
const cookies = request.media === true ? null : request.cookies;
|
||||
|
||||
get().openAddModalWithUrls(
|
||||
urls.join('\n'),
|
||||
request.referer,
|
||||
urls.length === 1 ? request.filename : null,
|
||||
request.headers,
|
||||
cookies,
|
||||
request.cookies,
|
||||
request.media === true
|
||||
);
|
||||
},
|
||||
@@ -687,7 +681,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
pauseDownload: async (id) => {
|
||||
const { generation } = await invalidateDispatch(id);
|
||||
const { generation, pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
}
|
||||
|
||||
await invoke('pause_download', { id });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendRequestUrlsAfterVersion,
|
||||
canSubmitMetadataRows,
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
@@ -93,6 +94,7 @@ describe('add download metadata workflow', () => {
|
||||
status: 'ready',
|
||||
generation: 4,
|
||||
requestContextVersion: 1,
|
||||
requestCookiesOmitted: true,
|
||||
formats: [{
|
||||
name: '1080p MP4',
|
||||
selector: '137+140',
|
||||
@@ -119,11 +121,30 @@ describe('add download metadata workflow', () => {
|
||||
status: 'loading',
|
||||
generation: 5,
|
||||
requestContextVersion: 2,
|
||||
requestCookiesOmitted: false,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('appends every unseen handoff after the observed version', () => {
|
||||
const merged = appendRequestUrlsAfterVersion(
|
||||
'https://existing.example/file.zip',
|
||||
{
|
||||
'https://first.example/file.zip': { version: 2 },
|
||||
'https://second.example/file.zip': { version: 3 },
|
||||
'https://existing.example/file.zip': { version: 4 }
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
expect(merged).toBe(
|
||||
'https://existing.example/file.zip\n' +
|
||||
'https://first.example/file.zip\n' +
|
||||
'https://second.example/file.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
||||
const existing = row({
|
||||
sourceUrl: 'https://adult.example/watch/123',
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface AddDownloadDraftRow {
|
||||
status: MetadataStatus;
|
||||
generation: number;
|
||||
requestContextVersion?: number;
|
||||
requestCookiesOmitted?: boolean;
|
||||
isMedia: boolean;
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
@@ -93,6 +94,7 @@ export const reconcileDownloadRows = (
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
requestCookiesOmitted: false,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||
@@ -120,6 +122,35 @@ export const reconcileDownloadRows = (
|
||||
});
|
||||
};
|
||||
|
||||
const comparableUrl = (rawUrl: string): string => {
|
||||
try {
|
||||
return new URL(rawUrl).href;
|
||||
} catch {
|
||||
return rawUrl.trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const appendRequestUrlsAfterVersion = (
|
||||
rawText: string,
|
||||
requestContexts: Readonly<Record<string, { version: number }>>,
|
||||
observedVersion: number
|
||||
): string => {
|
||||
const lines = rawText.split('\n').map(line => line.trim()).filter(Boolean);
|
||||
const seen = new Set(lines.map(comparableUrl));
|
||||
const additions = Object.entries(requestContexts)
|
||||
.filter(([, context]) => context.version > observedVersion)
|
||||
.sort(([, left], [, right]) => left.version - right.version);
|
||||
|
||||
for (const [url] of additions) {
|
||||
const identity = comparableUrl(url);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
lines.push(url);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
export const updateRowIfCurrent = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user