fix(downloads): support offline fallback drafts

- launch Firefox handoffs through authenticated reconnect
- preserve usable drafts when metadata lookup fails
- reject stale metadata results and retry failed rows only
This commit is contained in:
NimBold
2026-06-22 17:21:47 +03:30
parent f469c5d7eb
commit 6b3753949b
10 changed files with 745 additions and 183 deletions
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
canSubmitMetadataRows,
mediaFormatSelectorForRow,
metadataSummaryMessage,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
const row = (
overrides: Partial<AddDownloadDraftRow> = {}
): AddDownloadDraftRow => ({
id: 'row-1',
sourceUrl: 'https://example.com/file.zip',
downloadUrl: 'https://example.com/file.zip',
file: 'file.zip',
status: 'ready',
generation: 1,
isMedia: false,
...overrides
});
describe('add download metadata workflow', () => {
it('preserves rows by normalized source URL and creates only new rows', () => {
const existing = row({ file: 'server-name.zip' });
let nextId = 0;
const rows = reconcileDownloadRows(
'https://example.com/file.zip\nhttps://example.com/new.zip',
[existing],
undefined,
() => `new-${nextId++}`
);
expect(rows[0]).toBe(existing);
expect(rows[1]).toMatchObject({
id: 'new-0',
status: 'loading',
file: 'new.zip'
});
});
it('deduplicates normalized URLs and marks malformed or unsupported URLs invalid', () => {
let nextId = 0;
const rows = reconcileDownloadRows(
'https://example.com/a\nhttps://example.com/a\nfile:///tmp/private\nnot-a-url',
[],
undefined,
() => `row-${nextId++}`
);
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
});
it('refreshes only failed metadata and preserves successful format selection', () => {
const ready = row({
id: 'ready',
isMedia: true,
formats: [{
name: '1080p MP4',
selector: 'best',
ext: 'mp4',
formatLabel: '1080p',
detail: '10 MB',
type: 'Video',
bytes: 10
}],
selectedFormat: 0
});
const failed = row({ id: 'failed', status: 'metadata-error', generation: 4 });
const refreshed = refreshFailedMetadataRows([ready, failed]);
expect(refreshed[0]).toBe(ready);
expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 });
});
it('ignores stale metadata results after generation changes', () => {
const current = row({ generation: 2, status: 'loading' });
const updated = updateRowIfCurrent(
[current],
current.id,
current.sourceUrl,
1,
value => ({ ...value, status: 'ready' })
);
expect(updated[0]).toBe(current);
});
it('allows ready and failed rows but blocks loading and invalid rows', () => {
expect(canSubmitMetadataRows([
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe(true);
expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false);
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
});
it('keeps failed media routing without a format selector', () => {
const failedMedia = row({
status: 'metadata-error',
isMedia: true,
formats: undefined,
selectedFormat: undefined
});
expect(failedMedia.isMedia).toBe(true);
expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined();
});
it('reports fallback and invalid states accurately', () => {
expect(metadataSummaryMessage([
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe('1 download ready; 1 will use fallback filename and unknown size.');
expect(metadataSummaryMessage([
row({ status: 'metadata-error' })
])).toContain('can still be added');
expect(metadataSummaryMessage([
row({ status: 'invalid' })
])).toContain('Correct or remove 1 invalid URL');
});
});
+159
View File
@@ -0,0 +1,159 @@
import {
canonicalizeDownloadFileName,
fileNameFromUrl,
isMediaUrl
} from './downloads';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
export interface AddMediaFormat {
name: string;
selector: string;
ext: string;
formatLabel: string;
detail: string;
type: string;
bytes: number;
isApproximate?: boolean;
}
export interface AddDownloadDraftRow {
id: string;
sourceUrl: string;
downloadUrl: string;
file: string;
size?: string;
sizeBytes?: number;
status: MetadataStatus;
generation: number;
isMedia: boolean;
formats?: AddMediaFormat[];
selectedFormat?: number;
}
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
type ParsedInput = {
identity: string;
sourceUrl: string;
valid: boolean;
};
const parseInputLines = (rawText: string): ParsedInput[] => {
const seen = new Set<string>();
const parsed: ParsedInput[] = [];
for (const rawLine of rawText.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
let sourceUrl = line;
let valid = false;
try {
const url = new URL(line);
valid = ALLOWED_SCHEMES.has(url.protocol);
if (valid) sourceUrl = url.href;
} catch {
valid = false;
}
const identity = valid ? sourceUrl : `invalid:${line}`;
if (seen.has(identity)) continue;
seen.add(identity);
parsed.push({ identity, sourceUrl, valid });
}
return parsed;
};
export const reconcileDownloadRows = (
rawText: string,
currentRows: AddDownloadDraftRow[],
pendingFilename?: string,
createId: () => string = () => crypto.randomUUID()
): AddDownloadDraftRow[] => {
const inputs = parseInputLines(rawText);
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
return inputs.map(input => {
const preserved = existing.get(input.sourceUrl);
if (preserved) return preserved;
const fallback = canonicalizeDownloadFileName(
inputs.length === 1 && pendingFilename
? pendingFilename
: fileNameFromUrl(input.sourceUrl)
);
return {
id: createId(),
sourceUrl: input.sourceUrl,
downloadUrl: input.sourceUrl,
file: fallback,
status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0,
isMedia: input.valid && isMediaUrl(input.sourceUrl)
};
});
};
export const updateRowIfCurrent = (
rows: AddDownloadDraftRow[],
id: string,
sourceUrl: string,
generation: number,
update: (row: AddDownloadDraftRow) => AddDownloadDraftRow
): AddDownloadDraftRow[] => rows.map(row =>
row.id === id && row.sourceUrl === sourceUrl && row.generation === generation
? update(row)
: row
);
export const refreshFailedMetadataRows = (
rows: AddDownloadDraftRow[]
): AddDownloadDraftRow[] => rows.map(row =>
row.status === 'metadata-error'
? {
...row,
status: 'loading',
generation: row.generation + 1
}
: row
);
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
rows.length > 0
&& rows.every(row => row.status === 'ready' || row.status === 'metadata-error');
export const mediaFormatSelectorForRow = (
row: AddDownloadDraftRow
): string | undefined => {
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) {
return undefined;
}
return row.formats?.[row.selectedFormat]?.selector;
};
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
const invalid = rows.filter(row => row.status === 'invalid').length;
if (invalid > 0) {
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
}
const loading = rows.filter(row => row.status === 'loading').length;
if (loading > 0) {
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
const ready = rows.filter(row => row.status === 'ready').length;
if (failed === rows.length) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
}
if (failed > 0) {
return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`;
}
return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`;
};