mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'}.`;
|
||||
};
|
||||
Reference in New Issue
Block a user