Download history refactor (#700)

- Much simpler handling of downloads in the activity sidebar, and
improved storage, persistence and UI behavior.
- Replace `ActivityService` with direct storage on
`DownloadHistoryService` and `download_requests` and removes the
activity_log/activity_dismissals tables
- Simplify no-auth mode by removing the fake user row pattern, handled
internally
- Add local download fallback so history entries can still serve files
after tasks leave the queue
- Downloads, requests and history are now entirely persistent between
updates / restarts, and correctly tied to each user.
This commit is contained in:
Alex
2026-03-04 19:10:06 +00:00
committed by GitHub
parent bd65bccf52
commit fbe25725d3
26 changed files with 1682 additions and 1891 deletions
+32 -8
View File
@@ -135,6 +135,17 @@ type PendingOnBehalfDownload =
actingAsUser: ActingAsUserSelection;
};
const mergeTerminalBucket = (
persistedBucket: Record<string, Book> | undefined,
realtimeBucket: Record<string, Book> | undefined
): Record<string, Book> | undefined => {
const merged = {
...(persistedBucket || {}),
...(realtimeBucket || {}),
};
return Object.keys(merged).length > 0 ? merged : undefined;
};
function App() {
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
@@ -227,6 +238,7 @@ function App() {
});
const {
activityStatus,
requestItems,
dismissedActivityKeys,
historyItems,
@@ -286,6 +298,18 @@ function App() {
};
}, [currentStatus, dismissedDownloadTaskIds]);
// Use real-time buckets for active work and merge persisted terminal buckets
// so completed/errored entries survive restarts.
const activitySidebarStatus = useMemo<StatusData>(() => ({
queued: currentStatus.queued,
resolving: currentStatus.resolving,
locating: currentStatus.locating,
downloading: currentStatus.downloading,
complete: mergeTerminalBucket(activityStatus.complete, currentStatus.complete),
error: mergeTerminalBucket(activityStatus.error, currentStatus.error),
cancelled: mergeTerminalBucket(activityStatus.cancelled, currentStatus.cancelled),
}), [activityStatus, currentStatus]);
const showRequestsTab = useMemo(() => {
if (requestRoleIsAdmin) {
return true;
@@ -425,14 +449,14 @@ function App() {
};
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
currentStatus.locating,
currentStatus.downloading,
activitySidebarStatus.queued,
activitySidebarStatus.resolving,
activitySidebarStatus.locating,
activitySidebarStatus.downloading,
].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0);
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
const completed = countVisibleDownloads(activitySidebarStatus.complete, { filterDismissed: true });
const errored = countVisibleDownloads(activitySidebarStatus.error, { filterDismissed: true });
const pendingVisibleRequests = requestItems.filter((item) => {
const requestId = item.requestId;
if (!requestId || item.requestRecord?.status !== 'pending') {
@@ -447,7 +471,7 @@ function App() {
errored,
pendingRequests: pendingVisibleRequests,
};
}, [currentStatus, dismissedActivityKeys, requestItems]);
}, [activitySidebarStatus, dismissedActivityKeys, requestItems]);
// Compute visibility states
@@ -1602,7 +1626,7 @@ function App() {
<ActivitySidebar
isOpen={downloadsSidebarOpen}
onClose={() => setDownloadsSidebarOpen(false)}
status={currentStatus}
status={activitySidebarStatus}
isAdmin={requestRoleIsAdmin}
onClearCompleted={handleClearCompleted}
onCancel={handleCancel}
@@ -510,11 +510,14 @@ export const ActivityCard = ({
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
: 'text-sm truncate leading-tight min-w-0';
const titleNode =
const canShowDownloadLink =
item.kind === 'download' &&
item.visualStatus === 'complete' &&
item.downloadPath &&
item.downloadBookId ? (
Boolean(item.downloadBookId) &&
Boolean(item.downloadPath);
const titleNode =
canShowDownloadLink && item.downloadBookId ? (
<a
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(item.downloadBookId)}`)}
className="text-sky-600 hover:underline"
@@ -28,6 +28,19 @@ const toNumber = (value: unknown, fallback = 0): number => {
return fallback;
};
const toEpochMillis = (value: unknown): number => {
const parsed = toNumber(value, 0);
if (parsed <= 0) {
return 0;
}
// Queue `added_time` values are epoch seconds; request/history timestamps are ms.
// Normalize to ms so sorting is consistent across merged activity items.
if (parsed >= 1_000_000_000 && parsed < 1_000_000_000_000) {
return parsed * 1000;
}
return parsed;
};
const toSourceLabel = (value: unknown): string | undefined => {
const text = toOptionalText(value);
if (!text) {
@@ -94,7 +107,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
statusDetail,
progress,
progressAnimated: isActiveDownloadStatus(visualStatus),
timestamp: toNumber(book.added_time, 0),
timestamp: toEpochMillis(book.added_time),
username: toOptionalText(book.username),
downloadBookId: book.id,
downloadPath: toOptionalText(book.download_path),
+28 -9
View File
@@ -282,20 +282,35 @@ export const useActivity = ({
.map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp);
// Request history entries with attached release metadata are approval artifacts,
// not actual download outcomes. Keep history focused on concrete download results.
const nonAttachedReleaseRequestItems = mappedItems.filter((item) => {
if (item.kind !== 'request') {
return true;
}
const releaseData = item.requestRecord?.release_data;
if (!releaseData || typeof releaseData !== 'object') {
return true;
}
return Object.keys(releaseData as Record<string, unknown>).length === 0;
});
// Download dismissals already carry linked request context; hide redundant
// fulfilled-request history rows that would otherwise appear as "Approved".
const requestIdsWithDownloadRows = new Set<number>();
mappedItems.forEach((item) => {
nonAttachedReleaseRequestItems.forEach((item) => {
if (item.kind === 'download' && typeof item.requestId === 'number') {
requestIdsWithDownloadRows.add(item.requestId);
}
});
if (!requestIdsWithDownloadRows.size) {
return mappedItems;
return nonAttachedReleaseRequestItems;
}
return mappedItems.filter((item) => {
return nonAttachedReleaseRequestItems.filter((item) => {
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
return true;
}
@@ -389,12 +404,16 @@ export const useActivity = ({
const handleClearHistory = useCallback(() => {
resetActivityHistory();
void clearActivityHistory().catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
});
}, [refreshActivityHistory, resetActivityHistory, showToast]);
void clearActivityHistory()
.then(() => {
void refreshActivitySnapshot();
})
.catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
});
}, [refreshActivityHistory, refreshActivitySnapshot, resetActivityHistory, showToast]);
return {
activityStatus,
-7
View File
@@ -35,7 +35,6 @@ const API = {
cancelDownload: `${API_BASE}/download`,
retryDownload: `${API_BASE}/download`,
setPriority: `${API_BASE}/queue`,
clearCompleted: `${API_BASE}/queue/clear`,
config: `${API_BASE}/config`,
login: `${API_BASE}/auth/login`,
logout: `${API_BASE}/auth/logout`,
@@ -402,10 +401,6 @@ export const retryDownload = async (id: string): Promise<void> => {
await fetchJSON(`${API.retryDownload}/${encodeURIComponent(id)}/retry`, { method: 'POST' });
};
export const clearCompleted = async (): Promise<void> => {
await fetchJSON(`${API_BASE}/queue/clear`, { method: 'DELETE' });
};
export const getConfig = async (): Promise<AppConfig> => {
return fetchJSON<AppConfig>(API.config);
};
@@ -432,7 +427,6 @@ export interface ActivitySnapshotResponse {
export interface ActivityDismissPayload {
item_type: 'download' | 'request';
item_key: string;
activity_log_id?: number;
}
export interface ActivityHistoryItem {
@@ -440,7 +434,6 @@ export interface ActivityHistoryItem {
user_id: number;
item_type: 'download' | 'request';
item_key: string;
activity_log_id: number | null;
dismissed_at: string;
snapshot: Record<string, unknown> | null;
origin: 'direct' | 'request' | 'requested' | null;
@@ -80,6 +80,17 @@ describe('activityMappers.downloadToActivityItem', () => {
assert.equal(item.timestamp, 123);
});
it('normalizes epoch-second added_time values to milliseconds', () => {
const item = downloadToActivityItem(
makeBook({
added_time: 1700000000,
}),
'complete'
);
assert.equal(item.timestamp, 1700000000000);
});
it('maps downloading progress using 20 + progress*0.8', () => {
const item = downloadToActivityItem(makeBook({ progress: 60 }), 'downloading');
assert.equal(item.visualStatus, 'downloading');