diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 211f1e679..b0598454e 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -1660,7 +1660,10 @@ def download_settings() -> list[SettingsField]: CheckboxField( key="AUTO_OPEN_DOWNLOADS_SIDEBAR", label="Show Downloads When Queued", - description="Go to Activity > Downloads when a new download is queued.", + description=( + "When you start a download, go to the Activity page that lists it (Queued " + "or Downloads). Downloads queued in the background never move the page." + ), default=False, ), MultiSelectField( diff --git a/shelfmark/main.py b/shelfmark/main.py index 91f76f0bf..5b11d50e5 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1448,7 +1448,7 @@ def api_config() -> Response | tuple[Response, int]: "saved_auto_get_enabled": saved_auto_get_enabled(), # Whether the library browser has a library to show (admins only, like its API). "library_browser_available": _library_browser_available(), - "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), + "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", False), "hardcover_auto_remove_on_download": app_config.get( "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True ), diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1876ee7b1..b7eca699a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -681,9 +681,12 @@ function App() { const [mobileNavOpen, setMobileNavOpen] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); const headerObserverRef = useRef(null); - const showDownloadsPage = useCallback(() => { - void navigate('/activity/downloads'); - }, [navigate]); + const showDownloadsPage = useCallback( + (page: 'queued' | 'downloads') => { + void navigate(`/activity/${page}`); + }, + [navigate], + ); const headerRef = useCallback((el: HTMLDivElement | null) => { if (headerObserverRef.current) { headerObserverRef.current.disconnect(); diff --git a/src/frontend/src/hooks/app/useStatusChangeNotifications.ts b/src/frontend/src/hooks/app/useStatusChangeNotifications.ts index 1d58e4b5b..18520a0ca 100644 --- a/src/frontend/src/hooks/app/useStatusChangeNotifications.ts +++ b/src/frontend/src/hooks/app/useStatusChangeNotifications.ts @@ -1,13 +1,46 @@ import { useEffect, useEffectEvent, useRef } from 'react'; +import { takeQueuedHere } from '../../services/api'; import type { AppConfig, StatusData } from '../../types'; import { withBasePath } from '../../utils/basePath'; +// How soon after queuing a download here its arrival may open its list. +const QUEUED_HERE_WINDOW_MS = 30_000; + +const ACTIVE_BUCKETS = ['queued', 'resolving', 'locating', 'downloading'] as const; +const ALL_BUCKETS: readonly (keyof StatusData)[] = [ + ...ACTIVE_BUCKETS, + 'complete', + 'error', + 'cancelled', +]; + +/** Every download listed in `status`, whatever its state. */ +const listedIds = (status: StatusData): Set => + new Set(ALL_BUCKETS.flatMap((bucket) => Object.keys(status[bucket] ?? {}))); + +/** + * Where a download that is new since `prev` is listed: Queued while it waits to start, + * Downloads once under way. Null when none is new. A download that was already listed + * (moving back to the queue to wait for a slot, say) isn't new. + */ +export const newDownloadPage = ( + prev: StatusData, + next: StatusData, +): 'queued' | 'downloads' | null => { + const known = listedIds(prev); + const isNew = (bucket: (typeof ACTIVE_BUCKETS)[number]) => + Object.keys(next[bucket] ?? {}).some((id) => !known.has(id)); + if (isNew('queued')) return 'queued'; + return ACTIVE_BUCKETS.slice(1).some(isNew) ? 'downloads' : null; +}; + interface UseStatusChangeNotificationsOptions { currentStatus: StatusData; config: AppConfig | null; showToast: (message: string, type: 'info' | 'success' | 'error') => void; - showDownloadsPage: () => void; + // Opens the list a download just queued here is on (Activity > Queued or Downloads). + showDownloadsPage: (page: 'queued' | 'downloads') => void; bookToReleaseMap: Record; markBookCompleted: (bookId: string) => void; } @@ -32,20 +65,25 @@ export const useStatusChangeNotifications = ({ return autoDownloadContentTypes.includes(contentTypeKey); }; - const prevQueued = prevStatus.queued || {}; + // Only a download that's new, not one moving back to the queue (to wait for a slot). + const known = listedIds(prevStatus); const currQueued = nextStatus.queued || {}; - let shouldShowDownloads = false; Object.keys(currQueued).forEach((bookId) => { - if (!prevQueued[bookId]) { + if (!known.has(bookId)) { const book = currQueued[bookId]; showToast(`${book.title || 'Book'} added to queue`, 'info'); - if (config?.auto_open_downloads_sidebar !== false) { - shouldShowDownloads = true; - } } }); - if (shouldShowDownloads) { - showDownloadsPage(); + // The page moves only for a download queued from it, with the setting on: never + // for ones queued in the background (Saved, retries, other users), which would + // pull the reader away from whatever they were looking at. + const page = newDownloadPage(prevStatus, nextStatus); + if ( + page && + config?.auto_open_downloads_sidebar === true && + takeQueuedHere(QUEUED_HERE_WINDOW_MS) + ) { + showDownloadsPage(page); } const prevDownloading = prevStatus.downloading || {}; diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 9286dc635..40c89c121 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -607,6 +607,17 @@ export const inspectRelease = async ( }); }; +// When this page last queued a download itself: only those may move it to their list +// (the "Show Downloads When Queued" setting), never ones queued in the background. +let lastQueuedHereAt = 0; + +/** Whether this page queued a download in the last `withinMs`; true once, then forgotten. */ +export const takeQueuedHere = (withinMs: number): boolean => { + const recent = Date.now() - lastQueuedHereAt <= withinMs; + lastQueuedHereAt = 0; + return recent; +}; + export const downloadRelease = async ( release: DownloadReleasePayload, onBehalfOfUserId?: number, @@ -616,10 +627,17 @@ export const downloadRelease = async ( ? { ...release, on_behalf_of_user_id: onBehalfOfUserId } : release; - await fetchJSON(`${API_BASE}/releases/download`, { - method: 'POST', - body: JSON.stringify(payload), - }); + // Marked first: the status update announcing it can arrive before this reply does. + lastQueuedHereAt = Date.now(); + try { + await fetchJSON(`${API_BASE}/releases/download`, { + method: 'POST', + body: JSON.stringify(payload), + }); + } catch (error) { + lastQueuedHereAt = 0; + throw error; + } }; export const getStatus = async (): Promise => { diff --git a/src/frontend/src/tests/statusNavigation.test.ts b/src/frontend/src/tests/statusNavigation.test.ts new file mode 100644 index 000000000..a7a70233b --- /dev/null +++ b/src/frontend/src/tests/statusNavigation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { newDownloadPage } from '../hooks/app/useStatusChangeNotifications'; +import type { Book, StatusData } from '../types'; + +const book = (id: string): Book => ({ id, title: id, author: '' }); +const bucket = (...ids: string[]): Record => + Object.fromEntries(ids.map((id) => [id, book(id)])); + +describe('newDownloadPage', () => { + it('points a new download waiting to start at Queued, one under way at Downloads', () => { + const prev: StatusData = { downloading: bucket('a') }; + expect(newDownloadPage(prev, { downloading: bucket('a'), queued: bucket('b') })).toBe('queued'); + expect(newDownloadPage(prev, { downloading: bucket('a', 'b') })).toBe('downloads'); + }); + + it('ignores a download moving back to the queue to wait for a slot', () => { + const prev: StatusData = { resolving: bucket('a') }; + expect(newDownloadPage(prev, { queued: bucket('a') })).toBeNull(); + }); + + it('ignores downloads finishing or failing', () => { + const prev: StatusData = { downloading: bucket('a', 'b') }; + expect(newDownloadPage(prev, { complete: bucket('a'), error: bucket('b') })).toBeNull(); + }); +}); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index db61292e3..02bc97948 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -350,7 +350,7 @@ export interface AppConfig { saved_auto_get_enabled?: boolean; // Saved items can be marked to download on their own library_browser_available?: boolean; // A library check is on, so the library browser has books books_output_mode: BooksOutputMode; - auto_open_downloads_sidebar: boolean; // Go to Activity > Downloads when a download is queued + auto_open_downloads_sidebar: boolean; // Open a download's list when it's queued from here hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types release_search_timeout: number; // Server-side budget for one release search, in seconds