feat(media): support explicit fetch media intent

This commit is contained in:
NimBold
2026-07-07 12:44:19 +03:30
parent e30eff4d2e
commit f711c6b7a4
13 changed files with 268 additions and 47 deletions
+6 -2
View File
@@ -31,7 +31,7 @@ const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
const PROTOCOL_VERSION: &str = "3"; const PROTOCOL_VERSION: &str = "4";
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>; pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -61,6 +61,8 @@ struct ExtensionRequest {
headers: Option<String>, headers: Option<String>,
#[serde(default)] #[serde(default)]
cookies: Option<String>, cookies: Option<String>,
#[serde(default)]
media: bool,
} }
#[derive(Clone, Serialize, TS)] #[derive(Clone, Serialize, TS)]
@@ -72,6 +74,7 @@ pub struct ExtensionDownload {
filename: Option<String>, filename: Option<String>,
headers: Option<String>, headers: Option<String>,
cookies: Option<String>, cookies: Option<String>,
media: bool,
} }
pub async fn start_server( pub async fn start_server(
@@ -318,6 +321,7 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
filename, filename,
headers: payload.headers.filter(|value| !value.trim().is_empty()), headers: payload.headers.filter(|value| !value.trim().is_empty()),
cookies: payload.cookies.filter(|value| !value.trim().is_empty()), cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
media: payload.media,
}) })
} }
@@ -478,7 +482,7 @@ mod tests {
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!( assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"3" "4"
); );
server.abort(); server.abort();
+71 -10
View File
@@ -20,16 +20,22 @@ fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
fn metadata_info_cache_key( fn metadata_info_cache_key(
url: &str, url: &str,
cookie_source: Option<&str>, cookie_source: Option<&str>,
username: Option<&str>,
password: Option<&str>,
headers: Option<&str>, headers: Option<&str>,
cookies: Option<&str>, cookies: Option<&str>,
proxy: Option<&str>, proxy: Option<&str>,
user_agent: Option<&str>,
) -> String { ) -> String {
serde_json::json!([ serde_json::json!([
url, url,
cookie_source.unwrap_or(""), cookie_source.unwrap_or(""),
username.unwrap_or(""),
password.unwrap_or(""),
headers.unwrap_or(""), headers.unwrap_or(""),
cookies.unwrap_or(""), cookies.unwrap_or(""),
proxy.unwrap_or("") proxy.unwrap_or(""),
user_agent.unwrap_or("")
]) ])
.to_string() .to_string()
} }
@@ -1320,6 +1326,7 @@ async fn fetch_media_metadata(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
url: String, url: String,
cookie_browser: Option<String>, cookie_browser: Option<String>,
user_agent: Option<String>,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
headers: Option<String>, headers: Option<String>,
@@ -1370,6 +1377,7 @@ async fn fetch_media_metadata(
app_handle.clone(), app_handle.clone(),
url.clone(), url.clone(),
cookie_browser.clone(), cookie_browser.clone(),
user_agent.clone(),
username.clone(), username.clone(),
password.clone(), password.clone(),
headers.clone(), headers.clone(),
@@ -1387,7 +1395,7 @@ async fn fetch_media_metadata(
browser browser
); );
fetch_media_metadata_uncached( fetch_media_metadata_uncached(
app_handle, url, None, username, password, headers, cookies, proxy, app_handle, url, None, user_agent, username, password, headers, cookies, proxy,
) )
.await .await
} }
@@ -1427,6 +1435,7 @@ async fn fetch_media_metadata_uncached(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
url: String, url: String,
cookie_browser: Option<String>, cookie_browser: Option<String>,
user_agent: Option<String>,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
headers: Option<String>, headers: Option<String>,
@@ -1436,9 +1445,12 @@ async fn fetch_media_metadata_uncached(
let info_cache_key = metadata_info_cache_key( let info_cache_key = metadata_info_cache_key(
&url, &url,
cookie_browser.as_deref(), cookie_browser.as_deref(),
username.as_deref(),
password.as_deref(),
headers.as_deref(), headers.as_deref(),
cookies.as_deref(), cookies.as_deref(),
proxy.as_deref(), proxy.as_deref(),
user_agent.as_deref(),
); );
// Pass bundled tools by absolute path so extraction never depends on // Pass bundled tools by absolute path so extraction never depends on
@@ -1489,20 +1501,24 @@ async fn fetch_media_metadata_uncached(
} }
} }
if let Some(ua) = user_agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
cmd = cmd.arg("--user-agent").arg(ua);
}
let mut config_file = tempfile::Builder::new() let mut config_file = tempfile::Builder::new()
.prefix("ytdlp-") .prefix("ytdlp-")
.suffix(".conf") .suffix(".conf")
.tempfile() .tempfile()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut config_content = String::new(); let mut config_content = String::new();
if let Some(user) = username { if let Some(user) = username.as_deref() {
if !user.is_empty() { if !user.is_empty() {
append_ytdlp_config_option(&mut config_content, "--username", &user); append_ytdlp_config_option(&mut config_content, "--username", user);
} }
} }
if let Some(pass) = password { if let Some(pass) = password.as_deref() {
if !pass.is_empty() { if !pass.is_empty() {
append_ytdlp_config_option(&mut config_content, "--password", &pass); append_ytdlp_config_option(&mut config_content, "--password", pass);
} }
} }
append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?; append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?;
@@ -2678,14 +2694,14 @@ pub(crate) async fn start_media_download_internal(
.tempfile() .tempfile()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut config_content = String::new(); let mut config_content = String::new();
if let Some(user) = username { if let Some(user) = username.as_deref() {
if !user.is_empty() { if !user.is_empty() {
append_ytdlp_config_option(&mut config_content, "--username", &user); append_ytdlp_config_option(&mut config_content, "--username", user);
} }
} }
if let Some(pass) = password { if let Some(pass) = password.as_deref() {
if !pass.is_empty() { if !pass.is_empty() {
append_ytdlp_config_option(&mut config_content, "--password", &pass); append_ytdlp_config_option(&mut config_content, "--password", pass);
} }
} }
append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?; append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?;
@@ -2820,9 +2836,12 @@ pub(crate) async fn start_media_download_internal(
let info_cache_key = metadata_info_cache_key( let info_cache_key = metadata_info_cache_key(
&url, &url,
effective_cookie_source.as_deref(), effective_cookie_source.as_deref(),
username.as_deref(),
password.as_deref(),
headers.as_deref(), headers.as_deref(),
cookies.as_deref(), cookies.as_deref(),
proxy.as_deref(), proxy.as_deref(),
user_agent.as_deref(),
); );
if let Some(json_str) = cache.remove(&info_cache_key) { if let Some(json_str) = cache.remove(&info_cache_key) {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
@@ -4604,27 +4623,69 @@ mod tests {
let base = metadata_info_cache_key( let base = metadata_info_cache_key(
"https://example.com/watch?v=1", "https://example.com/watch?v=1",
Some("firefox"), Some("firefox"),
Some("user-one"),
Some("pass-one"),
Some("User-Agent: Browser A"), Some("User-Agent: Browser A"),
Some("session=one"), Some("session=one"),
None, None,
Some("Custom UA A"),
); );
let changed_headers = metadata_info_cache_key( let changed_headers = metadata_info_cache_key(
"https://example.com/watch?v=1", "https://example.com/watch?v=1",
Some("firefox"), Some("firefox"),
Some("user-one"),
Some("pass-one"),
Some("User-Agent: Browser B"), Some("User-Agent: Browser B"),
Some("session=one"), Some("session=one"),
None, None,
Some("Custom UA A"),
); );
let changed_cookies = metadata_info_cache_key( let changed_cookies = metadata_info_cache_key(
"https://example.com/watch?v=1", "https://example.com/watch?v=1",
Some("firefox"), Some("firefox"),
Some("user-one"),
Some("pass-one"),
Some("User-Agent: Browser A"), Some("User-Agent: Browser A"),
Some("session=two"), Some("session=two"),
None, None,
Some("Custom UA A"),
);
let changed_username = metadata_info_cache_key(
"https://example.com/watch?v=1",
Some("firefox"),
Some("user-two"),
Some("pass-one"),
Some("User-Agent: Browser A"),
Some("session=one"),
None,
Some("Custom UA A"),
);
let changed_password = metadata_info_cache_key(
"https://example.com/watch?v=1",
Some("firefox"),
Some("user-one"),
Some("pass-two"),
Some("User-Agent: Browser A"),
Some("session=one"),
None,
Some("Custom UA A"),
);
let changed_user_agent = metadata_info_cache_key(
"https://example.com/watch?v=1",
Some("firefox"),
Some("user-one"),
Some("pass-one"),
Some("User-Agent: Browser A"),
Some("session=one"),
None,
Some("Custom UA B"),
); );
assert_ne!(base, changed_headers); assert_ne!(base, changed_headers);
assert_ne!(base, changed_cookies); assert_ne!(base, changed_cookies);
assert_ne!(base, changed_username);
assert_ne!(base, changed_password);
assert_ne!(base, changed_user_agent);
} }
#[test] #[test]
+5
View File
@@ -478,6 +478,11 @@ pub static SUPPORTED_DOMAINS: &[&str] = &[
"reddit.com", "reddit.com",
"v.redd.it", "v.redd.it",
"soundcloud.com", "soundcloud.com",
"pornhub.com",
"redtube.com",
"xhamster.com",
"xnxx.com",
"xvideos.com",
]; ];
#[tauri::command] #[tauri::command]
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, }; export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
+12 -2
View File
@@ -57,6 +57,7 @@ export const AddDownloadsModal = () => {
pendingAddFilename, pendingAddFilename,
pendingAddHeaders, pendingAddHeaders,
pendingAddCookies, pendingAddCookies,
pendingAddMediaUrls,
toggleAddModal, toggleAddModal,
addDownload, addDownload,
queues queues
@@ -136,6 +137,7 @@ export const AddDownloadsModal = () => {
pendingAddReferer, pendingAddReferer,
pendingAddHeaders, pendingAddHeaders,
pendingAddCookies, pendingAddCookies,
pendingAddMediaUrls,
baseDownloadFolder, baseDownloadFolder,
perServerConnections perServerConnections
]); ]);
@@ -173,10 +175,17 @@ export const AddDownloadsModal = () => {
}, [saveLocation, isAddModalOpen]); }, [saveLocation, isAddModalOpen]);
useEffect(() => { useEffect(() => {
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
try {
return new URL(url).href;
} catch {
return url;
}
}));
setParsedItems(current => setParsedItems(current =>
reconcileDownloadRows(urls, current, pendingAddFilename || undefined) reconcileDownloadRows(urls, current, pendingAddFilename || undefined, forcedMediaUrls)
); );
}, [urls, pendingAddFilename]); }, [urls, pendingAddFilename, pendingAddMediaUrls]);
useEffect(() => { useEffect(() => {
for (const row of parsedItems) { for (const row of parsedItems) {
@@ -205,6 +214,7 @@ export const AddDownloadsModal = () => {
const mediaData = await fetchMediaMetadataDeduped({ const mediaData = await fetchMediaMetadataDeduped({
url: row.sourceUrl, url: row.sourceUrl,
cookieBrowser: browserArg, cookieBrowser: browserArg,
userAgent: settingsStore.customUserAgent.trim() || null,
username: useAuth ? username.trim() || null : login?.username || null, username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword, password: useAuth ? password || null : keychainPassword,
headers: headers?.trim() || null, headers: headers?.trim() || null,
+1 -1
View File
@@ -21,7 +21,7 @@ type CommandMap = {
result: MetadataResponse; result: MetadataResponse;
}; };
fetch_media_metadata: { fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaMetadata; result: MediaMetadata;
}; };
get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
+86 -22
View File
@@ -64,6 +64,7 @@ describe('useDownloadStore', () => {
pendingAddFilename: '', pendingAddFilename: '',
pendingAddHeaders: '', pendingAddHeaders: '',
pendingAddCookies: '', pendingAddCookies: '',
pendingAddMediaUrls: [],
}); });
}); });
@@ -487,7 +488,8 @@ describe('useDownloadStore', () => {
silent: false, silent: false,
filename: 'file.bin', filename: 'file.bin',
headers: 'X-Test: value', headers: 'X-Test: value',
cookies: 'session=secret' cookies: 'session=secret',
media: false
}); });
const state = useDownloadStore.getState(); const state = useDownloadStore.getState();
@@ -495,53 +497,115 @@ describe('useDownloadStore', () => {
expect(state.pendingAddUrls).toBe('https://example.com/file.bin'); expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
expect(state.pendingAddReferer).toBe('https://example.com/page'); expect(state.pendingAddReferer).toBe('https://example.com/page');
expect(state.pendingAddFilename).toBe('file.bin'); expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value'); expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret'); expect(state.pendingAddCookies).toBe('session=secret');
}); expect(state.pendingAddMediaUrls).toEqual([]);
});
it('does not reuse stale extension metadata for a later single-link handoff', async () => { it('does not reuse stale extension metadata for a later single-link handoff', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
isAddModalOpen: true, isAddModalOpen: true,
pendingAddUrls: '', pendingAddUrls: '',
pendingAddReferer: 'https://old.example/page', pendingAddReferer: 'https://old.example/page',
pendingAddFilename: '7aae36e6-00ec-4e7d-8dec-f14ace170bdb', pendingAddFilename: '7aae36e6-00ec-4e7d-8dec-f14ace170bdb',
pendingAddHeaders: 'X-Old: value', pendingAddHeaders: 'X-Old: value',
pendingAddCookies: 'old=session' pendingAddCookies: 'old=session',
}); pendingAddMediaUrls: []
});
await useDownloadStore.getState().handleExtensionDownload({ await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg'], urls: ['https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg'],
referer: 'https://github.com/center2055/OnionHop/releases/tag/v3.5', referer: 'https://github.com/center2055/OnionHop/releases/tag/v3.5',
silent: false, silent: false,
filename: null, filename: null,
headers: 'User-Agent: Firefox Test', headers: 'User-Agent: Firefox Test',
cookies: null cookies: null,
}); media: false
});
const state = useDownloadStore.getState(); const state = useDownloadStore.getState();
expect(state.pendingAddUrls).toBe('https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg'); expect(state.pendingAddUrls).toBe('https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg');
expect(state.pendingAddReferer).toBe('https://github.com/center2055/OnionHop/releases/tag/v3.5'); expect(state.pendingAddReferer).toBe('https://github.com/center2055/OnionHop/releases/tag/v3.5');
expect(state.pendingAddFilename).toBe(''); expect(state.pendingAddFilename).toBe('');
expect(state.pendingAddHeaders).toBe('User-Agent: Firefox Test'); expect(state.pendingAddHeaders).toBe('User-Agent: Firefox Test');
expect(state.pendingAddCookies).toBe(''); expect(state.pendingAddCookies).toBe('');
}); 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({ await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/downloads/report.pdf'], urls: ['https://example.com/downloads/report.pdf'],
referer: 'https://example.com/page', referer: 'https://example.com/page',
silent: true, silent: true,
filename: 'report.pdf', filename: 'report.pdf',
headers: 'User-Agent: Test', headers: 'User-Agent: Test',
cookies: 'session=secret' cookies: 'session=secret',
}); media: false
});
const state = useDownloadStore.getState(); const state = useDownloadStore.getState();
expect(state.isAddModalOpen).toBe(true); expect(state.isAddModalOpen).toBe(true);
expect(state.pendingAddUrls).toBe('https://example.com/downloads/report.pdf'); expect(state.pendingAddUrls).toBe('https://example.com/downloads/report.pdf');
expect(state.pendingAddReferer).toBe('https://example.com/page'); expect(state.pendingAddReferer).toBe('https://example.com/page');
expect(state.pendingAddFilename).toBe('report.pdf'); expect(state.pendingAddFilename).toBe('report.pdf');
expect(state.pendingAddHeaders).toBe('User-Agent: Test'); expect(state.pendingAddHeaders).toBe('User-Agent: Test');
expect(state.pendingAddCookies).toBe('session=secret'); expect(state.pendingAddCookies).toBe('session=secret');
}); expect(state.pendingAddMediaUrls).toEqual([]);
});
it('preserves explicit extension media intent for non-allow-listed pages', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://adult.example/watch/123'],
referer: 'https://adult.example/watch/123',
silent: false,
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: 'session=secret',
media: true
});
const state = useDownloadStore.getState();
expect(state.isAddModalOpen).toBe(true);
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
});
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
useDownloadStore.setState({
isAddModalOpen: true,
pendingAddUrls: 'https://adult.example/watch/123',
pendingAddMediaUrls: ['https://adult.example/watch/123']
});
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://adult.example/watch/123'],
referer: 'https://adult.example/watch/123',
silent: false,
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: 'session=secret',
media: true
});
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
'https://adult.example/watch/123'
]);
useDownloadStore.setState({
isAddModalOpen: false,
pendingAddMediaUrls: ['https://stale.example/watch']
});
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'],
referer: 'https://example.com/page',
silent: false,
filename: 'file.bin',
headers: 'User-Agent: Firefox Test',
cookies: null,
media: false
});
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
});
}); });
+18 -5
View File
@@ -252,6 +252,7 @@ interface DownloadState {
pendingAddFilename: string; pendingAddFilename: string;
pendingAddHeaders: string; pendingAddHeaders: string;
pendingAddCookies: string; pendingAddCookies: string;
pendingAddMediaUrls: string[];
selectedPropertiesDownloadId: string | null; selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => void; toggleAddModal: (isOpen: boolean) => void;
openAddModalWithUrls: ( openAddModalWithUrls: (
@@ -259,7 +260,8 @@ interface DownloadState {
referer?: string | null, referer?: string | null,
filename?: string | null, filename?: string | null,
headers?: string | null, headers?: string | null,
cookies?: string | null cookies?: string | null,
media?: boolean
) => void; ) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState; deleteModalState: DeleteModalState;
@@ -383,6 +385,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddFilename: '', pendingAddFilename: '',
pendingAddHeaders: '', pendingAddHeaders: '',
pendingAddCookies: '', pendingAddCookies: '',
pendingAddMediaUrls: [],
selectedPropertiesDownloadId: null, selectedPropertiesDownloadId: null,
deleteModalState: { isOpen: false }, deleteModalState: { isOpen: false },
openDeleteModal: (downloadIds) => set({ openDeleteModal: (downloadIds) => set({
@@ -398,18 +401,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddReferer: '', pendingAddReferer: '',
pendingAddFilename: '', pendingAddFilename: '',
pendingAddHeaders: '', pendingAddHeaders: '',
pendingAddCookies: '' pendingAddCookies: '',
pendingAddMediaUrls: []
}), }),
openAddModalWithUrls: (urls, referer, filename, headers, cookies) => set((state) => { openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : ''; const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : '';
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
const existingMediaUrls = state.isAddModalOpen ? state.pendingAddMediaUrls : [];
const pendingAddMediaUrls = media
? [...new Set([
...existingMediaUrls,
...urls.split('\n').map(url => url.trim()).filter(Boolean)
])]
: existingMediaUrls;
return { return {
isAddModalOpen: true, isAddModalOpen: true,
pendingAddUrls: mergedUrls, pendingAddUrls: mergedUrls,
pendingAddReferer: referer?.trim() || '', pendingAddReferer: referer?.trim() || '',
pendingAddFilename: filename?.trim() || '', pendingAddFilename: filename?.trim() || '',
pendingAddHeaders: headers?.trim() || '', pendingAddHeaders: headers?.trim() || '',
pendingAddCookies: cookies?.trim() || '' pendingAddCookies: cookies?.trim() || '',
pendingAddMediaUrls
}; };
}), }),
handleExtensionDownload: async (request) => { handleExtensionDownload: async (request) => {
@@ -421,7 +433,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
request.referer, request.referer,
urls.length === 1 ? request.filename : null, urls.length === 1 ? request.filename : null,
request.headers, request.headers,
request.cookies request.cookies,
request.media === true
); );
}, },
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
+44
View File
@@ -30,6 +30,7 @@ describe('add download metadata workflow', () => {
'https://example.com/file.zip\nhttps://example.com/new.zip', 'https://example.com/file.zip\nhttps://example.com/new.zip',
[existing], [existing],
undefined, undefined,
new Set(),
() => `new-${nextId++}` () => `new-${nextId++}`
); );
@@ -47,12 +48,55 @@ describe('add download metadata workflow', () => {
'https://example.com/a\nhttps://example.com/a\nfile:///tmp/private\nnot-a-url', 'https://example.com/a\nhttps://example.com/a\nfile:///tmp/private\nnot-a-url',
[], [],
undefined, undefined,
new Set(),
() => `row-${nextId++}` () => `row-${nextId++}`
); );
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']); expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
}); });
it('forces explicit extension media fetches through media metadata for any http page', () => {
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
[],
undefined,
new Set(['https://adult.example/watch/123'])
);
expect(rows[0]).toMatchObject({
sourceUrl: 'https://adult.example/watch/123',
isMedia: true,
status: 'loading'
});
});
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
const existing = row({
sourceUrl: 'https://adult.example/watch/123',
downloadUrl: 'https://adult.example/watch/123',
file: '123',
status: 'ready',
generation: 2,
isMedia: false
});
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
[existing],
undefined,
new Set(['https://adult.example/watch/123'])
);
expect(rows[0]).toMatchObject({
sourceUrl: 'https://adult.example/watch/123',
isMedia: true,
status: 'loading',
generation: 3,
formats: undefined,
selectedFormat: undefined
});
});
it('refreshes only failed metadata and preserves successful format selection', () => { it('refreshes only failed metadata and preserves successful format selection', () => {
const ready = row({ const ready = row({
id: 'ready', id: 'ready',
+15 -2
View File
@@ -71,6 +71,7 @@ export const reconcileDownloadRows = (
rawText: string, rawText: string,
currentRows: AddDownloadDraftRow[], currentRows: AddDownloadDraftRow[],
pendingFilename?: string, pendingFilename?: string,
forceMediaUrls: ReadonlySet<string> = new Set(),
createId: () => string = () => crypto.randomUUID() createId: () => string = () => crypto.randomUUID()
): AddDownloadDraftRow[] => { ): AddDownloadDraftRow[] => {
const inputs = parseInputLines(rawText); const inputs = parseInputLines(rawText);
@@ -78,7 +79,19 @@ export const reconcileDownloadRows = (
return inputs.map(input => { return inputs.map(input => {
const preserved = existing.get(input.sourceUrl); const preserved = existing.get(input.sourceUrl);
if (preserved) return preserved; if (preserved) {
if (input.valid && forceMediaUrls.has(input.sourceUrl) && !preserved.isMedia) {
return {
...preserved,
status: 'loading',
generation: preserved.generation + 1,
isMedia: true,
formats: undefined,
selectedFormat: undefined
};
}
return preserved;
}
const fallback = canonicalizeDownloadFileName( const fallback = canonicalizeDownloadFileName(
inputs.length === 1 && pendingFilename inputs.length === 1 && pendingFilename
@@ -93,7 +106,7 @@ export const reconcileDownloadRows = (
file: fallback, file: fallback,
status: input.valid ? 'loading' : 'invalid', status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0, generation: input.valid ? 1 : 0,
isMedia: input.valid && isMediaUrl(input.sourceUrl) isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
}; };
}); });
}; };
+6 -1
View File
@@ -18,7 +18,12 @@ let MEDIA_DOMAINS = [
'v.redd.it', 'v.redd.it',
'soundcloud.com', 'soundcloud.com',
'facebook.com', 'facebook.com',
'fb.watch' 'fb.watch',
'pornhub.com',
'redtube.com',
'xhamster.com',
'xnxx.com',
'xvideos.com'
]; ];
const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
+2
View File
@@ -4,6 +4,7 @@ import type { MediaMetadata } from '../bindings/MediaMetadata';
type FetchMediaMetadataArgs = { type FetchMediaMetadataArgs = {
url: string; url: string;
cookieBrowser: string | null; cookieBrowser: string | null;
userAgent: string | null;
username: string | null; username: string | null;
password: string | null; password: string | null;
headers: string | null; headers: string | null;
@@ -17,6 +18,7 @@ const metadataKey = (args: FetchMediaMetadataArgs) =>
JSON.stringify([ JSON.stringify([
args.url, args.url,
args.cookieBrowser, args.cookieBrowser,
args.userAgent,
args.username, args.username,
args.password, args.password,
args.headers, args.headers,