mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): harden capture and media flows
Scope extension request context to each Add modal row, refresh stale metadata handoffs, and align yt-dlp format and retry behavior with Firelink's transfer contract.
This commit is contained in:
+43
-4
@@ -536,7 +536,7 @@ fn build_media_format_options(
|
||||
let mut options = Vec::new();
|
||||
|
||||
if has_video {
|
||||
let available_heights: Vec<u64> = [2160_u64, 1440, 1080, 720, 480, 360]
|
||||
let available_heights: Vec<u64> = [4320_u64, 2160, 1440, 1080, 720, 480, 360, 240, 144]
|
||||
.into_iter()
|
||||
.filter(|height| {
|
||||
clean_formats
|
||||
@@ -2876,9 +2876,15 @@ pub(crate) async fn start_media_download_internal(
|
||||
.arg("--socket-timeout")
|
||||
.arg("20")
|
||||
.arg("--retries")
|
||||
.arg(max_retries.to_string())
|
||||
// Firelink owns the retry budget so `maxAutomaticRetries` means
|
||||
// the same thing for aria2 and media downloads. Letting yt-dlp
|
||||
// also consume that value would multiply the configured retry
|
||||
// count on every outer restart.
|
||||
.arg("0")
|
||||
.arg("--extractor-retries")
|
||||
.arg("3")
|
||||
.arg("0")
|
||||
.arg("--fragment-retries")
|
||||
.arg("0")
|
||||
.arg("--ffmpeg-location")
|
||||
.arg(&ffmpeg_path)
|
||||
.arg("--js-runtimes")
|
||||
@@ -2943,7 +2949,15 @@ pub(crate) async fn start_media_download_internal(
|
||||
} else if safe_filename.ends_with(".webm") {
|
||||
cmd = cmd.arg("--merge-output-format").arg("webm");
|
||||
} else {
|
||||
cmd = cmd.arg("--merge-output-format").arg("mkv");
|
||||
// `--merge-output-format` only affects split video/audio
|
||||
// selections. A progressive MP4/WebM stream already includes
|
||||
// audio, so also request remuxing to keep an MKV option from
|
||||
// producing a mismatched file extension and container.
|
||||
cmd = cmd
|
||||
.arg("--merge-output-format")
|
||||
.arg("mkv")
|
||||
.arg("--remux-video")
|
||||
.arg("mkv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4984,6 +4998,31 @@ mod tests {
|
||||
assert_eq!(option.filesize_approx, Some(1_468_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_low_and_ultra_high_available_video_qualities() {
|
||||
let formats = vec![
|
||||
json!({
|
||||
"format_id": "401",
|
||||
"ext": "webm",
|
||||
"height": 4320,
|
||||
"vcodec": "av01.0.17M.08",
|
||||
"acodec": "none"
|
||||
}),
|
||||
json!({
|
||||
"format_id": "17",
|
||||
"ext": "mp4",
|
||||
"height": 144,
|
||||
"vcodec": "avc1.42E01E",
|
||||
"acodec": "mp4a.40.2"
|
||||
})
|
||||
];
|
||||
|
||||
let options = build_media_format_options(&formats, Some(60.0));
|
||||
|
||||
assert!(options.iter().any(|format| format.resolution == "4320p"));
|
||||
assert!(options.iter().any(|format| format.resolution == "144p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_no_media_options_for_webpage_only_metadata() {
|
||||
let formats = vec![json!({
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
useDownloadStore,
|
||||
getSiteLogin,
|
||||
getProxyArgs,
|
||||
type AddDownloadAction
|
||||
type AddDownloadAction,
|
||||
type PendingAddRequestContext
|
||||
} from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
||||
@@ -39,16 +40,6 @@ const formatBytes = (bytes: number) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const isCrossHostRedirect = (sourceUrl: string, downloadUrl: string) => {
|
||||
try {
|
||||
const sourceHost = new URL(sourceUrl).hostname;
|
||||
const downloadHost = new URL(downloadUrl).hostname;
|
||||
return downloadHost !== sourceHost && !downloadHost.endsWith(`.${sourceHost}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeComparableUrl = (rawUrl: string) => {
|
||||
try {
|
||||
return new URL(rawUrl).href;
|
||||
@@ -57,6 +48,11 @@ const normalizeComparableUrl = (rawUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
|
||||
context?.referer ? `Referer: ${context.referer.replace(/[\r\n]/g, '')}` : '',
|
||||
context?.headers
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
@@ -67,6 +63,9 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddHeaders,
|
||||
pendingAddCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion,
|
||||
pendingAddLatestUrls,
|
||||
toggleAddModal,
|
||||
addDownload,
|
||||
queues
|
||||
@@ -106,43 +105,76 @@ export const AddDownloadsModal = () => {
|
||||
const [checksumValue, setChecksumValue] = useState('');
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [cookies, setCookies] = useState('');
|
||||
const headersFromExtensionRef = useRef(false);
|
||||
const cookiesFromExtensionRef = useRef(false);
|
||||
const headersManuallyEditedRef = useRef(false);
|
||||
const cookiesManuallyEditedRef = useRef(false);
|
||||
const modalSessionRef = useRef(false);
|
||||
const observedRequestVersionRef = useRef(0);
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
const requestContextForUrl = (url: string) =>
|
||||
pendingAddRequestContexts[normalizeComparableUrl(url)];
|
||||
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
|
||||
const headersForRow = (sourceUrl: string) => {
|
||||
if (headersManuallyEditedRef.current) return headers.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context) return extensionHeaders(context).trim();
|
||||
return hasExtensionRequestContext ? '' : headers.trim();
|
||||
};
|
||||
const cookiesForRow = (sourceUrl: string) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context) return context.cookies.trim();
|
||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||
};
|
||||
const suggestedFilenameForRow = (sourceUrl: string) => {
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context?.filename) return context.filename;
|
||||
return hasExtensionRequestContext ? '' : pendingAddFilename;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddModalOpen) {
|
||||
setSaveLocation(baseDownloadFolder);
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(pendingAddUrls || '');
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
setConnections(perServerConnections);
|
||||
setSpeedLimitEnabled(false);
|
||||
setSpeedLimit('1024');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
const extensionHeaders = [
|
||||
pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '',
|
||||
pendingAddHeaders
|
||||
].filter(Boolean).join('\n');
|
||||
setHeaders(extensionHeaders);
|
||||
headersFromExtensionRef.current = Boolean(extensionHeaders.trim());
|
||||
setCookies(pendingAddCookies);
|
||||
cookiesFromExtensionRef.current = Boolean(pendingAddCookies?.trim());
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
if (!isAddModalOpen) {
|
||||
modalSessionRef.current = false;
|
||||
setUrls('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSessionRef.current) return;
|
||||
modalSessionRef.current = true;
|
||||
const initialUrls = pendingAddUrls || '';
|
||||
const initialUrlLines = initialUrls.split('\n').map(url => url.trim()).filter(Boolean);
|
||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||
const initialContext = initialUrlLines.length === 1
|
||||
? requestContextForUrl(initialUrlLines[0])
|
||||
: undefined;
|
||||
|
||||
setSaveLocation(baseDownloadFolder);
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(initialUrls);
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setPendingDestinationOverrides({});
|
||||
setConnections(perServerConnections);
|
||||
setSpeedLimitEnabled(false);
|
||||
setSpeedLimit('1024');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
setHeaders(initialContext ? extensionHeaders(initialContext) : [
|
||||
pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '',
|
||||
pendingAddHeaders
|
||||
].filter(Boolean).join('\n'));
|
||||
headersManuallyEditedRef.current = false;
|
||||
setCookies(initialContext?.cookies || pendingAddCookies);
|
||||
cookiesManuallyEditedRef.current = false;
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
setIsSubmitting(false);
|
||||
}, [
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
@@ -154,6 +186,18 @@ export const AddDownloadsModal = () => {
|
||||
perServerConnections
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen || !modalSessionRef.current
|
||||
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQueueMenuOpen) return;
|
||||
const closeMenu = (event: PointerEvent) => {
|
||||
@@ -194,10 +238,33 @@ export const AddDownloadsModal = () => {
|
||||
return url;
|
||||
}
|
||||
}));
|
||||
setParsedItems(current =>
|
||||
reconcileDownloadRows(urls, current, pendingAddFilename || undefined, forcedMediaUrls)
|
||||
const requestFilenames = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.filter(([, context]) => Boolean(context.filename))
|
||||
.map(([url, context]) => [url, context.filename])
|
||||
);
|
||||
}, [urls, pendingAddFilename, pendingAddMediaUrls]);
|
||||
const requestContextVersions = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.map(([url, context]) => [url, context.version])
|
||||
);
|
||||
setParsedItems(current =>
|
||||
reconcileDownloadRows(
|
||||
urls,
|
||||
current,
|
||||
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
|
||||
forcedMediaUrls,
|
||||
undefined,
|
||||
requestFilenames,
|
||||
requestContextVersions
|
||||
)
|
||||
);
|
||||
}, [
|
||||
urls,
|
||||
pendingAddFilename,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
hasExtensionRequestContext
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const row of parsedItems) {
|
||||
@@ -223,67 +290,19 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const rowHeaders = headersForRow(row.sourceUrl);
|
||||
const rowCookies = cookiesForRow(row.sourceUrl);
|
||||
const mediaMetadataArgs = {
|
||||
url: row.sourceUrl,
|
||||
cookieBrowser: browserArg,
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headers?.trim() || null,
|
||||
cookies: cookies?.trim() || null,
|
||||
headers: rowHeaders || null,
|
||||
cookies: rowCookies || null,
|
||||
proxy
|
||||
};
|
||||
const cleanMediaMetadataArgs = {
|
||||
...mediaMetadataArgs,
|
||||
headers: null,
|
||||
cookies: null
|
||||
};
|
||||
const isExtensionMediaRow = pendingAddMediaUrls
|
||||
.map(normalizeComparableUrl)
|
||||
.includes(normalizeComparableUrl(row.sourceUrl));
|
||||
const hasExtensionContext =
|
||||
headersFromExtensionRef.current || cookiesFromExtensionRef.current;
|
||||
let mediaData;
|
||||
if (isExtensionMediaRow && hasExtensionContext) {
|
||||
try {
|
||||
mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs);
|
||||
if (headersFromExtensionRef.current) {
|
||||
setHeaders('');
|
||||
headersFromExtensionRef.current = false;
|
||||
}
|
||||
if (cookiesFromExtensionRef.current) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
} catch (cleanError) {
|
||||
console.warn(
|
||||
'Media metadata failed without extension browser context; retrying with extension headers/cookies',
|
||||
cleanError
|
||||
);
|
||||
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
} catch (error) {
|
||||
if (!hasExtensionContext) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
'Media metadata failed with extension browser context; retrying without extension headers/cookies',
|
||||
error
|
||||
);
|
||||
mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs);
|
||||
if (headersFromExtensionRef.current) {
|
||||
setHeaders('');
|
||||
headersFromExtensionRef.current = false;
|
||||
}
|
||||
if (cookiesFromExtensionRef.current) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
const mappedFormats = mediaData.formats.map(f => {
|
||||
const quality = f.resolution || 'Video';
|
||||
@@ -337,15 +356,11 @@ export const AddDownloadsModal = () => {
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headers?.trim() || null,
|
||||
cookies: cookies?.trim() || null,
|
||||
headers: headersForRow(row.sourceUrl) || null,
|
||||
cookies: cookiesForRow(row.sourceUrl) || null,
|
||||
proxy
|
||||
});
|
||||
const nextDownloadUrl = meta.url || row.sourceUrl;
|
||||
if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) {
|
||||
setCookies('');
|
||||
cookiesFromExtensionRef.current = false;
|
||||
}
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
current,
|
||||
row.id,
|
||||
@@ -355,8 +370,8 @@ export const AddDownloadsModal = () => {
|
||||
...currentRow,
|
||||
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
|
||||
file: canonicalizeDownloadFileName(
|
||||
current.length === 1 && pendingAddFilename
|
||||
? pendingAddFilename
|
||||
current.length === 1 && suggestedFilenameForRow(row.sourceUrl)
|
||||
? suggestedFilenameForRow(row.sourceUrl)
|
||||
: meta.filename
|
||||
),
|
||||
size: meta.size_bytes ? meta.size : undefined,
|
||||
@@ -690,11 +705,11 @@ export const AddDownloadsModal = () => {
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
headers: headersForRow(item.sourceUrl) || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
cookies: cookiesForRow(item.sourceUrl) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination
|
||||
? finalLocation
|
||||
@@ -1089,7 +1104,7 @@ export const AddDownloadsModal = () => {
|
||||
<textarea
|
||||
value={headers}
|
||||
onChange={e => {
|
||||
headersFromExtensionRef.current = false;
|
||||
headersManuallyEditedRef.current = true;
|
||||
setHeaders(e.target.value);
|
||||
}}
|
||||
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
|
||||
@@ -1102,7 +1117,7 @@ export const AddDownloadsModal = () => {
|
||||
type="text"
|
||||
value={cookies}
|
||||
onChange={e => {
|
||||
cookiesFromExtensionRef.current = false;
|
||||
cookiesManuallyEditedRef.current = true;
|
||||
setCookies(e.target.value);
|
||||
}}
|
||||
placeholder="name=value; other=value"
|
||||
|
||||
@@ -88,6 +88,9 @@ describe('useDownloadStore', () => {
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
pendingAddLatestUrls: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -616,7 +619,7 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes silent extension captures to the Add Modal instead of queuing immediately', async () => {
|
||||
it('routes silent extension captures to the Add Modal instead of queuing immediately', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/downloads/report.pdf'],
|
||||
referer: 'https://example.com/page',
|
||||
@@ -637,6 +640,50 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://first.example/file.zip'],
|
||||
referer: 'https://first.example/page',
|
||||
silent: true,
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session',
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://second.example/file.zip'],
|
||||
referer: 'https://second.example/page',
|
||||
silent: true,
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session',
|
||||
media: false
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddUrls).toBe(
|
||||
'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'
|
||||
},
|
||||
'https://second.example/file.zip': {
|
||||
version: 2,
|
||||
referer: 'https://second.example/page',
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit extension media intent for non-allow-listed pages', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://adult.example/watch/123'],
|
||||
|
||||
@@ -237,6 +237,13 @@ export type AddDownloadAction =
|
||||
| { type: 'start-now' }
|
||||
| { type: 'add-to-queue'; queueId: string };
|
||||
export type DownloadDraft = Omit<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'>;
|
||||
export type PendingAddRequestContext = {
|
||||
version: number;
|
||||
referer: string;
|
||||
filename: string;
|
||||
headers: string;
|
||||
cookies: string;
|
||||
};
|
||||
|
||||
export type DeleteModalState = {
|
||||
isOpen: boolean;
|
||||
@@ -261,6 +268,9 @@ interface DownloadState {
|
||||
pendingAddHeaders: string;
|
||||
pendingAddCookies: string;
|
||||
pendingAddMediaUrls: string[];
|
||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||
pendingAddRequestVersion: number;
|
||||
pendingAddLatestUrls: string;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (
|
||||
@@ -394,6 +404,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
pendingAddLatestUrls: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
deleteModalState: { isOpen: false },
|
||||
openDeleteModal: (downloadIds) => set({
|
||||
@@ -410,26 +423,60 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddFilename: '',
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
pendingAddMediaUrls: []
|
||||
pendingAddMediaUrls: [],
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddLatestUrls: ''
|
||||
}),
|
||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
||||
const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : '';
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||
const existingMediaUrls = state.isAddModalOpen ? state.pendingAddMediaUrls : [];
|
||||
const existingMediaUrls = isAppending ? state.pendingAddMediaUrls : [];
|
||||
const pendingAddMediaUrls = media
|
||||
? [...new Set([
|
||||
...existingMediaUrls,
|
||||
...urls.split('\n').map(url => url.trim()).filter(Boolean)
|
||||
])]
|
||||
: existingMediaUrls;
|
||||
const cleanReferer = referer?.trim() || '';
|
||||
const cleanFilename = filename?.trim() || '';
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: mergedUrls,
|
||||
pendingAddReferer: referer?.trim() || '',
|
||||
pendingAddFilename: filename?.trim() || '',
|
||||
pendingAddHeaders: headers?.trim() || '',
|
||||
pendingAddCookies: cookies?.trim() || '',
|
||||
pendingAddMediaUrls
|
||||
pendingAddReferer: cleanReferer,
|
||||
pendingAddFilename: cleanFilename,
|
||||
pendingAddHeaders: cleanHeaders,
|
||||
pendingAddCookies: cleanCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddRequestContexts,
|
||||
pendingAddRequestVersion: requestVersion,
|
||||
pendingAddLatestUrls: urls
|
||||
};
|
||||
}),
|
||||
handleExtensionDownload: async (request) => {
|
||||
|
||||
@@ -71,6 +71,59 @@ describe('add download metadata workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps extension-provided filenames scoped to their individual URLs', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://first.example/download\nhttps://second.example/download',
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => crypto.randomUUID(),
|
||||
{
|
||||
'https://first.example/download': 'first.zip',
|
||||
'https://second.example/download': 'second.zip'
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.map(item => item.file)).toEqual(['first.zip', 'second.zip']);
|
||||
});
|
||||
|
||||
it('refreshes an existing row when a newer extension handoff changes its request context', () => {
|
||||
const existing = row({
|
||||
isMedia: true,
|
||||
status: 'ready',
|
||||
generation: 4,
|
||||
requestContextVersion: 1,
|
||||
formats: [{
|
||||
name: '1080p MP4',
|
||||
selector: '137+140',
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
}],
|
||||
selectedFormat: 0
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
() => 'unused',
|
||||
{},
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 5,
|
||||
requestContextVersion: 2,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
||||
const existing = row({
|
||||
sourceUrl: 'https://adult.example/watch/123',
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface AddDownloadDraftRow {
|
||||
sizeBytes?: number;
|
||||
status: MetadataStatus;
|
||||
generation: number;
|
||||
requestContextVersion?: number;
|
||||
isMedia: boolean;
|
||||
resumable?: boolean;
|
||||
formats?: AddMediaFormat[];
|
||||
@@ -72,7 +73,9 @@ export const reconcileDownloadRows = (
|
||||
currentRows: AddDownloadDraftRow[],
|
||||
pendingFilename?: string,
|
||||
forceMediaUrls: ReadonlySet<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID()
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(rawText);
|
||||
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
|
||||
@@ -80,23 +83,28 @@ export const reconcileDownloadRows = (
|
||||
return inputs.map(input => {
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
if (input.valid && forceMediaUrls.has(input.sourceUrl) && !preserved.isMedia) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = requestContextVersions[input.sourceUrl];
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
|
||||
return {
|
||||
...preserved,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
isMedia: true,
|
||||
formats: undefined,
|
||||
selectedFormat: undefined
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia,
|
||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
const requestedFilename = requestFilenames[input.sourceUrl]
|
||||
|| (inputs.length === 1 ? pendingFilename : undefined);
|
||||
const fallback = canonicalizeDownloadFileName(
|
||||
inputs.length === 1 && pendingFilename
|
||||
? pendingFilename
|
||||
: fileNameFromUrl(input.sourceUrl)
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -106,6 +114,7 @@ export const reconcileDownloadRows = (
|
||||
file: fallback,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
requestContextVersion: requestContextVersions[input.sourceUrl],
|
||||
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user