Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion shelfmark/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion shelfmark/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down
9 changes: 6 additions & 3 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,9 +681,12 @@ function App() {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const headerObserverRef = useRef<ResizeObserver | null>(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();
Expand Down
56 changes: 47 additions & 9 deletions src/frontend/src/hooks/app/useStatusChangeNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<string> =>
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<string, string[]>;
markBookCompleted: (bookId: string) => void;
}
Expand All @@ -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 || {};
Expand Down
26 changes: 22 additions & 4 deletions src/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<StatusData> => {
Expand Down
26 changes: 26 additions & 0 deletions src/frontend/src/tests/statusNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Book> =>
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();
});
});
2 changes: 1 addition & 1 deletion src/frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading