feat(activity): clearer cards, a Rejected view and Reopen for failed deliveries (#1618)

A batch of activity list changes from running it as an admin on my own
instance. They are separate commits that build on each other, so they
can be reviewed in order.

The cards read better. Every card gets a format pill (violet for audio,
orange for ebooks), the requester is named once, and finished cards say
how long a download took and how long ago it ended ("took 5m · 35m
ago"). A download that picked up work already under way shows a
Reattached pill.

Admins can tell what is automatic and what needs a person. A declined
request shows who declined it and when, anything with an `[auto]` note
reads "Declined automatically", and "Needs Review" becomes "No Match
Yet" with a line saying the search repeats on every sync.

There is a Rejected view next to History for admins, with Reopen on each
card and a way to hide the dead ones.

The part to read most carefully is the backend in `requests_service.py`
and `user_db.py`. An admin can now reopen a fulfilled request whose
download failed or was cancelled, and the reopen a failed download
triggers remembers which release failed, so the next search doesn't pick
it again.

I ran it on a scratch instance (when pulling them together to move from
my fork to upstream) as an admin with seeded requests and looked at the
pills, the durations, the wording and the Rejected view in a browser.
Reopening a fulfilled request I only exercised in the tests. The
frontend and Python suites, type check, ruff and basedpyright pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Admins can review rejected requests in a dedicated view, hide or
restore cards, and reopen requests.
* Fulfilled requests with failed or cancelled deliveries can be
reopened; the failed release is skipped in the next automatic search.
* Activity cards now show format badges, download timing, and when a
download resumes existing work.
* **Improvements**
* Activity views clarify automatic retries and actions, and show
reviewer names for admins.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-09 12:02:21 -06:00
1 parent be803ed5ca
commit e575ff0fcb
34 files changed
+1769 -133

No files matched your search

+11 -1
View File
@@ -120,4 +120,14 @@ Admins can override the default ebook/audiobook modes and request rules for indi
4. For fulfilled requests, delivery state is tracked through the download pipeline
5. If delivery fails and the download cannot be retried, the request goes back to pending automatically so an admin can approve it again with a different release. An admin can also reopen a rejected request
6. Users can cancel their own pending requests
7. An admin can **reopen** a rejected request, from the Requests tab or from History. It goes back to pending with what the user originally asked for, and shows in the activity list again even if the user had dismissed it.
7. An admin can **reopen** a rejected request, from the Requests tab or from History. It goes back to pending with what the user originally asked for, and shows in the activity list again even if the user had dismissed it. A fulfilled request whose download failed or was cancelled can be reopened the same way: it goes back to pending without its release, and the next automatic search skips the release that failed (an admin can still pick it by hand).
An admin sees who declined a request and when on its card, with the admin note if one was given.
### Rejected view (this fork)
A button next to History opens **Rejected**: every rejected request, including ones cleared from the activity list, each with **Reopen**. A card can be hidden from this view, and **Show hidden (N)** in its header brings hidden ones back, so a request is never out of reach. Hiding here is separate from clearing a card in Activity.
Every card shows the format as a coloured pill: **Audiobook** or **Ebook** on a request, or the file format (EPUB, M4B, MP3) once a release is chosen or downloaded. Audio is violet and ebooks are orange.
Admin actions taken by scripts or automation carry an admin note starting `[auto]`, for example `[auto] Already in the library.`, so they can be told apart from decisions a person made.
+3
View File
@@ -28,6 +28,7 @@ from shelfmark.core.request_helpers import (
emit_ws_event,
extract_release_source_id,
normalize_positive_int,
populate_request_reviewers,
populate_request_usernames,
)
from shelfmark.core.request_validation import RequestStatus
@@ -336,9 +337,11 @@ def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]:
def _list_visible_requests(
user_db: UserDB, *, is_admin: bool, db_user_id: int | None
) -> list[dict[str, Any]]:
"""Every request with requester and reviewer names for an admin; a user's own, or none."""
if is_admin:
request_rows = user_db.list_requests()
populate_request_usernames(request_rows, user_db)
populate_request_reviewers(request_rows, user_db)
return request_rows
if db_user_id is None:
@@ -214,6 +214,12 @@ class DownloadHistoryService:
row.get("download_path")
),
"added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")),
# When it ended (epoch seconds, like added_time); None while it is still active.
"finished_at": (
None
if row.get("final_status") == "active"
else DownloadHistoryService._iso_to_epoch(row.get("terminal_at"))
),
"user_id": row.get("user_id"),
"username": row.get("username"),
"request_id": row.get("request_id"),
+7
View File
@@ -155,6 +155,10 @@ class DownloadTask:
# Hardlinking support
original_download_path: str | None = None # Path in download client (for hardlinking)
# True when the task picked up work that was already under way (a torrent the client had,
# files already on disk) instead of starting it. The activity card shows it as a pill.
reattached: bool = False
# Search mode - determines post-download processing behavior
# See SearchMode enum for behavioral differences
search_mode: SearchMode | None = None
@@ -186,6 +190,9 @@ class DownloadTask:
progress: float = 0.0
status: QueueStatus = QueueStatus.QUEUED
status_message: str | None = None
# When the task first reached a terminal status (epoch seconds, like added_time); None
# while it is running or queued again. Mirrors history's terminal_at for a live entry.
finished_at: float | None = None
download_path: str | None = None
# Every file the output step produced, download_path among them. Kept in memory
# only: history persists download_path alone.
+7 -5
View File
@@ -78,6 +78,7 @@ class BookQueue:
# Ensure added_time is set
if task.added_time == 0:
task.added_time = time.time()
task.finished_at = None
queue_item = QueueItem(task_id, task.priority, task.added_time)
self._queue.put(queue_item)
@@ -178,13 +179,13 @@ class BookQueue:
return None, None
self._update_status(book_id, status)
if (
status in TERMINAL_QUEUE_STATUSES
and previous_status != status
and self._terminal_status_hook is not None
):
if status in TERMINAL_QUEUE_STATUSES and previous_status != status:
current_task = self._task_data.get(book_id)
if current_task is not None:
# Stamped before the hook runs, so a snapshot taken while history is still
# being finalized already knows when the download ended.
current_task.finished_at = time.time()
if self._terminal_status_hook is not None:
hook = self._terminal_status_hook
hook_task = current_task
@@ -339,6 +340,7 @@ class BookQueue:
if priority is not None:
task.priority = priority
task.finished_at = None
# Ensure task doesn't appear active while waiting for retry.
self._active_downloads.pop(task_id, None)
+17
View File
@@ -180,6 +180,23 @@ def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> N
row["username"] = cache[requester_id]
def populate_request_reviewers(rows: list[dict[str, Any]], user_db: object) -> None:
"""Add 'reviewer_username' to each reviewed request row. Admin views only."""
if not _is_user_db_like(user_db):
return
cache: dict[int, str] = {}
for row in rows:
reviewer_id = normalize_positive_int(row.get("reviewed_by"))
if reviewer_id is None:
row["reviewer_username"] = ""
continue
if reviewer_id not in cache:
reviewer = user_db.get_user(user_id=reviewer_id)
cache[reviewer_id] = reviewer.get("username", "") if reviewer else ""
row["reviewer_username"] = cache[reviewer_id]
def extract_release_source_id(release_data: object) -> str | None:
"""Extract and normalize release_data.source_id."""
if not isinstance(release_data, dict):
+33
View File
@@ -21,6 +21,7 @@ from shelfmark.core.request_helpers import (
load_users_request_policy_settings,
normalize_optional_text,
normalize_positive_int,
populate_request_reviewers,
populate_request_usernames,
)
from shelfmark.core.request_policy import (
@@ -938,6 +939,7 @@ def register_request_routes(
@app.route("/api/admin/requests", methods=["GET"])
def api_admin_list_requests() -> ResponseReturnValue:
"""List requests for an admin, filtered by ?status and paged by ?limit and ?offset."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
@@ -954,6 +956,7 @@ def register_request_routes(
return jsonify({"error": str(exc)}), 400
populate_request_usernames(rows, user_db)
populate_request_reviewers(rows, user_db)
return jsonify(rows)
@@ -1103,6 +1106,36 @@ def register_request_routes(
return jsonify(updated)
@app.route("/api/admin/requests/rejected-hidden", methods=["GET"])
def api_admin_list_hidden_rejected() -> ResponseReturnValue:
"""Ids of the rejected requests an admin hid from the Rejected view."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return jsonify({"request_ids": user_db.list_hidden_rejected_request_ids()})
@app.route("/api/admin/requests/<int:request_id>/rejected-hidden", methods=["POST"])
def api_admin_set_rejected_hidden(request_id: int) -> ResponseReturnValue:
"""Hide or show a rejected request in the Rejected view; 409 if it is not rejected."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
_admin_user_id, admin_gate = _require_admin_user_id()
if admin_gate is not None:
return admin_gate
data = request.get_json(silent=True) or {}
hidden = data.get("hidden") if isinstance(data, dict) else None
if not isinstance(hidden, bool):
return jsonify({"error": "hidden must be true or false"}), 400
if not user_db.set_rejected_request_hidden(request_id, hidden=hidden):
return _error_response(
"Only a rejected request can be hidden", 409, code="stale_transition"
)
return jsonify({"request_id": request_id, "hidden": hidden})
@app.route("/api/admin/requests/<int:request_id>/reopen", methods=["POST"])
def api_admin_reopen_request(request_id: int) -> ResponseReturnValue:
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
+18 -3
View File
@@ -545,15 +545,18 @@ def reopen_request(
admin_user_id: int,
admin_note: object = None,
) -> dict[str, Any]:
"""Move a rejected request back to pending as admin, so the rejection can be undone."""
"""Move a rejected request, or a fulfilled one whose delivery failed or was cancelled, back to pending."""
request_row = ensure_request_access(
user_db,
request_id=request_id,
actor_user_id=admin_user_id,
is_admin=True,
)
if request_row["status"] != RequestStatus.REJECTED:
msg = "Only a rejected request can be reopened"
delivery_failed = request_row["status"] == RequestStatus.FULFILLED and request_row.get(
"delivery_state"
) in {QueueStatus.ERROR, QueueStatus.CANCELLED}
if request_row["status"] != RequestStatus.REJECTED and not delivery_failed:
msg = "Only a rejected request, or one whose download failed or was cancelled, can be reopened"
raise RequestServiceError(msg, status_code=409, code="stale_transition")
normalized_admin_note = _normalize_admin_note(admin_note)
@@ -575,6 +578,18 @@ def reopen_request(
msg = "Duplicate pending request exists for this title/author/content_type"
raise RequestServiceError(msg, status_code=409, code="duplicate_pending_request")
if delivery_failed:
# The chosen release is cleared, so the next pass searches again; the failure
# reason says who asked for it. The release that failed is remembered so the next
# automatic search skips it; an admin can still pick it by hand. The note lands in
# the same write, so a reopened request is never left without it.
reopened = user_db.reopen_failed_request(
request_id,
failure_reason=normalized_admin_note or "Reopened by an admin after a failed download",
remember_failed_release=True,
admin_note=normalized_admin_note,
)
else:
reopened = user_db.reopen_rejected_request(request_id, admin_note=normalized_admin_note)
if reopened is None:
msg = "Request state changed before update"
+65 -2
View File
@@ -121,6 +121,13 @@ WHERE dismissed_at IS NOT NULL AND cleared_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_activity_view_state_hidden
ON activity_view_state (viewer_scope, item_type, item_key)
WHERE dismissed_at IS NOT NULL;
-- Fork: rejected requests an admin hid from the Rejected view. Separate from
-- activity_view_state, which is the activity list's own clear/dismiss state.
CREATE TABLE IF NOT EXISTS rejected_request_hidden (
request_id INTEGER PRIMARY KEY REFERENCES download_requests(id) ON DELETE CASCADE,
hidden_at TEXT NOT NULL
);
"""
@@ -945,16 +952,21 @@ class UserDB:
*,
failure_reason: str | None = None,
remember_failed_release: bool = False,
admin_note: str | None = None,
) -> dict[str, Any] | None:
"""Reopen a failed fulfilled request so admins can re-approve it.
With ``remember_failed_release``, the release it was fulfilled with is added to
``book_data["failed_releases"]`` in the same write that reopens it, so a search that
picks the request up as soon as it is pending already skips that release.
picks the request up as soon as it is pending already skips that release. An
``admin_note`` is written in that same write; None leaves the current note alone.
"""
normalized_failure_reason = None
if isinstance(failure_reason, str):
normalized_failure_reason = failure_reason.strip() or None
normalized_admin_note = None
if isinstance(admin_note, str):
normalized_admin_note = admin_note.strip() or None
with self._lock:
conn = self._connect()
@@ -1001,11 +1013,12 @@ class UserDB:
release_data = NULL,
book_data = COALESCE(?, book_data),
last_failure_reason = ?,
admin_note = COALESCE(?, admin_note),
reviewed_by = NULL,
reviewed_at = NULL
WHERE id = ?
""",
(book_data_json, normalized_failure_reason, request_id),
(book_data_json, normalized_failure_reason, normalized_admin_note, request_id),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
@@ -1016,6 +1029,52 @@ class UserDB:
finally:
conn.close()
def list_hidden_rejected_request_ids(self) -> list[int]:
"""Ids of rejected requests hidden from the admin Rejected view."""
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT h.request_id FROM rejected_request_hidden h
JOIN download_requests r ON r.id = h.request_id
WHERE r.status = 'rejected'
ORDER BY h.request_id
"""
).fetchall()
return [int(row["request_id"]) for row in rows]
finally:
conn.close()
def set_rejected_request_hidden(self, request_id: int, *, hidden: bool) -> bool:
"""Hide or show a rejected request in the Rejected view. False if it is not rejected."""
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"SELECT status FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
if row is None or row["status"] != RequestStatus.REJECTED:
return False
if hidden:
conn.execute(
"""
INSERT INTO rejected_request_hidden (request_id, hidden_at)
VALUES (?, datetime('now'))
ON CONFLICT(request_id) DO NOTHING
""",
(request_id,),
)
else:
conn.execute(
"DELETE FROM rejected_request_hidden WHERE request_id = ?",
(request_id,),
)
conn.commit()
return True
finally:
conn.close()
def reopen_rejected_request(
self,
request_id: int,
@@ -1053,6 +1112,10 @@ class UserDB:
""",
(admin_note, request_id),
)
conn.execute(
"DELETE FROM rejected_request_hidden WHERE request_id = ?",
(request_id,),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
@@ -1149,6 +1149,9 @@ class ExternalClientHandler(DownloadHandler, ABC):
status_callback("resolving", f"Checking {client.name}")
category = self._get_category_for_task(client, task)
existing = client.find_existing(request.url, category=category)
# A torrent the client already had is joined, not added again; the card says so.
# Set here so every path below, including a copy of a finished download, carries it.
task.reattached = existing is not None
if existing:
download_id, existing_status = existing
+2
View File
@@ -740,6 +740,8 @@ def _task_to_dict(
"username": task.username,
"request_id": task.request_id,
"retry_available": can_retry_download_task(task, retry_status),
"reattached": bool(task.reattached),
"finished_at": task.finished_at,
}
@@ -32,6 +32,7 @@ interface ActivityCardProps {
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestRejectConfirm?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestReopen?: (requestId: number) => Promise<void> | void;
onRequestSetHidden?: (requestId: number, hidden: boolean) => Promise<void> | void;
onRequestDismiss?: (requestId: number) => void;
showRequestDetailsToggle?: boolean;
isRequestDetailsOpen?: boolean;
@@ -69,6 +70,19 @@ const IconButton = ({
</button>
);
// Fork: one colour per format, picked from hues the status badges do not use.
const FORMAT_PILL_CLASSES: Record<'audiobook' | 'ebook', string> = {
audiobook: 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200',
ebook: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-200',
};
// Fork: a download that picked up work already under way (a torrent the client had, files
// already on disk). Sky blue, a hue neither the status badges nor the format pills use.
const REATTACHED_PILL_CLASSES = 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200';
type ActionIconName = 'cross' | 'check' | 'stop' | 'retry' | 'eye' | 'eye-off';
/** A React key for an action button: its kind plus the download or request it acts on. */
const actionKey = (action: ActivityCardAction): string => {
switch (action.kind) {
case 'download-remove':
@@ -80,6 +94,8 @@ const actionKey = (action: ActivityCardAction): string => {
return `${action.kind}-${action.requestId}-${action.record.id}`;
case 'request-reject':
case 'request-reopen':
case 'request-hide':
case 'request-unhide':
case 'request-cancel':
case 'request-dismiss':
return `${action.kind}-${action.requestId}`;
@@ -90,7 +106,7 @@ const actionKey = (action: ActivityCardAction): string => {
const actionUiConfig = (
action: ActivityCardAction,
): { title: string; className: string; icon: 'cross' | 'check' | 'stop' | 'retry' } => {
): { title: string; className: string; icon: ActionIconName } => {
switch (action.kind) {
case 'download-remove':
return {
@@ -135,6 +151,18 @@ const actionUiConfig = (
className: 'text-sky-600 dark:text-sky-400 hover:bg-sky-100 dark:hover:bg-sky-900/30',
icon: 'retry',
};
case 'request-hide':
return {
title: 'Hide from Rejected',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye-off',
};
case 'request-unhide':
return {
title: 'Show in Rejected again',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye',
};
case 'request-cancel':
return {
title: 'Cancel request',
@@ -156,7 +184,28 @@ const actionUiConfig = (
}
};
const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' | 'retry' }) => {
/** The small inline SVG for an action button. */
const ActionIcon = ({ icon }: { icon: ActionIconName }) => {
if (icon === 'eye' || icon === 'eye-off') {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z"
/>
<circle cx="12" cy="12" r="2.75" />
{icon === 'eye-off' && <path strokeLinecap="round" d="M4 4l16 16" />}
</svg>
);
}
if (icon === 'stop') {
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -494,6 +543,7 @@ export const ActivityCard = ({
onRequestReject,
onRequestRejectConfirm,
onRequestReopen,
onRequestSetHidden,
onRequestDismiss,
showRequestDetailsToggle = false,
isRequestDetailsOpen = false,
@@ -505,6 +555,7 @@ export const ActivityCard = ({
}: ActivityCardProps) => {
const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]);
const noteLine = model.noteLine;
const formatPill = item.formatPill;
const badgeRefs = useRef<Record<string, HTMLSpanElement | null>>({});
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
@@ -581,6 +632,7 @@ export const ActivityCard = ({
const reviewApproveHandler = onRequestReviewApprove || onRequestApprove;
const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen;
/** Call the handler the parent passed for this action; approve may open the details instead. */
const runAction = (action: ActivityCardAction) => {
switch (action.kind) {
case 'download-remove':
@@ -612,6 +664,12 @@ export const ActivityCard = ({
case 'request-reopen':
void onRequestReopen?.(action.requestId);
break;
case 'request-hide':
void onRequestSetHidden?.(action.requestId, true);
break;
case 'request-unhide':
void onRequestSetHidden?.(action.requestId, false);
break;
case 'request-cancel':
onRequestCancel?.(action.requestId);
break;
@@ -623,6 +681,7 @@ export const ActivityCard = ({
}
};
/** Whether the parent passed a handler for this action, so the button is worth showing. */
const hasActionHandler = (action: ActivityCardAction): boolean => {
switch (action.kind) {
case 'download-remove':
@@ -638,6 +697,9 @@ export const ActivityCard = ({
return Boolean(onRequestReject);
case 'request-reopen':
return Boolean(onRequestReopen);
case 'request-hide':
case 'request-unhide':
return Boolean(onRequestSetHidden);
case 'request-cancel':
return Boolean(onRequestCancel);
case 'request-dismiss':
@@ -801,9 +863,42 @@ export const ActivityCard = ({
</div>
</div>
<p className="mt-0.5 truncate text-[11px] leading-tight opacity-60" title={item.metaLine}>
{formatPill ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${FORMAT_PILL_CLASSES[formatPill.kind]}`}
>
{formatPill.label}
</span>
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
{item.metaLine && (
<span className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</span>
)}
</div>
) : (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
<p className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
</div>
)}
{noteLine && (
<p className="mt-0.5 truncate text-[11px] italic opacity-60" title={noteLine}>
@@ -3,9 +3,18 @@ import { useCallback, useMemo, useRef, useState, type WheelEvent } from 'react';
import { useTabIndicator } from '../../hooks/ui/useTabIndicator';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useRejectedRequests } from '../../hooks/useRejectedRequests';
import type { RequestRecord, StatusData } from '../../types';
import { Dropdown } from '../Dropdown';
import { ActivityCard } from './ActivityCard';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
type ActivityCategoryKey,
type ActivityTabKey,
} from './activityCategories';
import type { DownloadStatusKey } from './activityMappers';
import { downloadToActivityItem, linkedDownloadIdForRequest } from './activityMappers';
import type { ActivityItem } from './activityTypes';
@@ -65,89 +74,8 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
'cancelled',
];
type ActivityCategoryKey = 'needs_review' | 'in_progress' | 'complete' | 'failed';
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
const ALL_USERS_FILTER = '__all_users__';
const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
return isAdmin ? 'Needs Review' : 'Waiting';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'requests') {
return ['needs_review', 'in_progress', 'complete', 'failed'];
}
if (tab === 'history') {
return [];
}
return ['needs_review', 'in_progress', 'complete', 'failed'];
};
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
const mergeRequestWithDownload = (
requestItem: ActivityItem,
downloadItem: ActivityItem,
@@ -249,26 +177,46 @@ export const ActivitySidebar = ({
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(() => new Set(dismissedItemKeys), [dismissedItemKeys]);
const { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden } = useRejectedRequests();
const [showHiddenRejected, setShowHiddenRejected] = useState(false);
const hiddenRejectedCount = rejectedItems.filter((item) => item.hiddenInRejected).length;
const handleTabChange = useCallback(
(nextTab: ActivityTabKey) => {
if (nextTab === 'downloads') {
setRejectingRequest(null);
setReviewingRequestId(null);
}
if (nextTab === 'rejected') {
void loadRejected();
}
setActiveTab(nextTab);
onActiveTabChange?.(nextTab);
},
[onActiveTabChange],
[loadRejected, onActiveTabChange],
);
const isPinnedOpen = isOpen && isDesktop && isPinned;
const effectiveActiveTab = !showRequestsTab && activeTab === 'requests' ? 'all' : activeTab;
let effectiveActiveTab = activeTab;
if (!showRequestsTab && activeTab === 'requests') {
effectiveActiveTab = 'all';
} else if (!isAdmin && activeTab === 'rejected') {
effectiveActiveTab = 'all';
}
if (effectiveActiveTab !== activeTab) {
setActiveTab(effectiveActiveTab);
}
useEscapeKey(isOpen && !isPinnedOpen, onClose);
/** Reopen a rejected request, then reload the list whether or not the call succeeded. */
const handleRejectedReopen = useCallback(
(requestId: number) =>
Promise.resolve(onRequestReopen?.(requestId)).finally(() => {
void loadRejected();
}),
[loadRejected, onRequestReopen],
);
const downloadItems = useMemo(() => {
const items: ActivityItem[] = [];
@@ -401,6 +349,10 @@ export const ActivitySidebar = ({
});
} else if (effectiveActiveTab === 'history') {
baseVisibleItems = historyItems;
} else if (effectiveActiveTab === 'rejected') {
baseVisibleItems = showHiddenRejected
? rejectedItems
: rejectedItems.filter((item) => !item.hiddenInRejected);
}
const isHistoryInitialLoad = effectiveActiveTab === 'history' && !historyLoaded;
let emptyStateMessage = 'No activity';
@@ -411,6 +363,14 @@ export const ActivitySidebar = ({
historyLoading || isHistoryInitialLoad ? 'Loading history...' : 'No history';
} else if (effectiveActiveTab === 'downloads') {
emptyStateMessage = 'No downloads';
} else if (effectiveActiveTab === 'rejected') {
if (rejectedLoading) {
emptyStateMessage = 'Loading rejected requests...';
} else if (hiddenRejectedCount > 0 && !showHiddenRejected) {
emptyStateMessage = `No rejected requests shown (${hiddenRejectedCount} hidden)`;
} else {
emptyStateMessage = 'No rejected requests';
}
}
const availableUsers = useMemo(() => {
@@ -486,7 +446,7 @@ export const ActivitySidebar = ({
);
const groupedVisibleItems = useMemo(() => {
if (effectiveActiveTab === 'history') {
if (effectiveActiveTab === 'history' || effectiveActiveTab === 'rejected') {
return [];
}
@@ -508,6 +468,7 @@ export const ActivitySidebar = ({
.map((key) => ({
key,
label: getCategoryLabel(key, isAdmin),
hint: getCategoryHint(key, isAdmin),
items: (grouped.get(key) ?? []).toSorted((left, right) => right.timestamp - left.timestamp),
}))
.filter((group) => group.items.length > 0);
@@ -540,7 +501,9 @@ export const ActivitySidebar = ({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{effectiveActiveTab === 'history' ? 'History' : 'Activity'}
{effectiveActiveTab === 'history' && 'History'}
{effectiveActiveTab === 'rejected' && 'Rejected'}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && 'Activity'}
</h2>
<button
type="button"
@@ -656,6 +619,36 @@ export const ActivitySidebar = ({
)}
</Dropdown>
)}
{isAdmin && (
<button
type="button"
onClick={() =>
handleTabChange(effectiveActiveTab === 'rejected' ? 'all' : 'rejected')
}
className={`hover-action relative inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors ${
effectiveActiveTab === 'rejected' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-label={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-pressed={effectiveActiveTab === 'rejected'}
>
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6.2 6.2l11.6 11.6" />
</svg>
</button>
)}
<button
type="button"
onClick={() => handleTabChange(effectiveActiveTab === 'history' ? 'all' : 'history')}
@@ -703,7 +696,20 @@ export const ActivitySidebar = ({
</div>
</div>
{effectiveActiveTab !== 'history' && (
{effectiveActiveTab === 'rejected' && (
<div className="mt-2 flex items-center justify-end text-sm">
<label className="inline-flex cursor-pointer items-center gap-2 opacity-80">
<input
type="checkbox"
checked={showHiddenRejected}
onChange={(event) => setShowHiddenRejected(event.target.checked)}
/>
Show hidden ({hiddenRejectedCount})
</label>
</div>
)}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && (
<div className="-mx-4 mt-2 border-b border-(--border-muted) px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
@@ -786,6 +792,22 @@ export const ActivitySidebar = ({
return <p className="mt-8 text-center text-sm opacity-70">{emptyStateMessage}</p>;
}
if (effectiveActiveTab === 'rejected') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{visibleItems.map((item) => (
<ActivityCard
key={item.id}
item={item}
isAdmin={isAdmin}
onRequestReopen={onRequestReopen ? handleRejectedReopen : undefined}
onRequestSetHidden={setRejectedHidden}
/>
))}
</div>
);
}
if (effectiveActiveTab === 'history') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
@@ -844,6 +866,9 @@ export const ActivitySidebar = ({
</span>
</button>
)}
{group.hint && !collapsedGroups[group.key] && (
<p className="px-0 pb-1.5 text-[11px] leading-snug opacity-60">{group.hint}</p>
)}
{!collapsedGroups[group.key] && (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{group.items.map((item) => {
@@ -0,0 +1,35 @@
// Fork: scripts and the automatic passes act through the admin account and mark what they did with
// an "[auto] " admin note. The cards say "automatically" for those, so a person is not shown as
// the one who declined or approved something they never saw.
const AUTO_PREFIX = /^\s*\[auto\]\s*/i;
/** Whether an admin note starts with the "[auto] " marker that scripts and passes write. */
export const isAutomaticNote = (note: string | null | undefined): boolean =>
typeof note === 'string' && AUTO_PREFIX.test(note);
/** The note without its "[auto] " marker. */
export const stripAutomaticMarker = (note: string): string => note.replace(AUTO_PREFIX, '').trim();
interface RetryFields {
status?: string;
admin_note?: string | null;
last_failure_reason?: string | null;
}
/**
* A pending request the automation is retrying by itself (reopened after a failed or interrupted
* download), as opposed to one it searched and found nothing for. Nothing for a person to do.
*/
export const isRetryingAutomatically = (record: RetryFields | null | undefined): boolean => {
if (!record || record.status !== 'pending') {
return false;
}
const note = (record.admin_note ?? '').trim().toLowerCase();
const reason = (record.last_failure_reason ?? '').trim().toLowerCase();
return (
note.startsWith('[auto] retry') ||
reason.startsWith('retrying') ||
reason.startsWith('interrupted by a restart')
);
};
@@ -1,4 +1,9 @@
import type { RequestRecord } from '../../types';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from './activityAutomation';
import { isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
@@ -14,7 +19,13 @@ export type ActivityCardAction =
record: RequestRecord;
}
| {
kind: 'request-reject' | 'request-reopen' | 'request-cancel' | 'request-dismiss';
kind:
| 'request-reject'
| 'request-reopen'
| 'request-hide'
| 'request-unhide'
| 'request-cancel'
| 'request-dismiss';
requestId: number;
};
@@ -51,14 +62,15 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS
return 'cancelled';
};
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
if (!isAdmin) {
return 'Awaiting review';
}
const username = item.username?.trim() || item.requestRecord?.username?.trim();
return username ? `Needs review · ${username}` : 'Needs review';
/** The badge text for a pending request. The requester is named on the meta line, not here. */
const getPendingRequestText = (_item: ActivityItem, isAdmin: boolean): string => {
return isAdmin ? 'No match yet' : 'Awaiting review';
};
/**
* The status badge for a request card. A failure reason on a pending request shows as an error,
* an automatic retry says so, and a linked download in flight shows as approved.
*/
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
const requestVisualStatus = item.requestRecord
? toRequestVisualStatus(item.requestRecord.status)
@@ -79,6 +91,10 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = 'Approved';
} else if (isRetryingAutomatically(item.requestRecord)) {
// Fork: the automation reopened it after a failed download; the next pass searches again.
text = 'Retrying automatically';
visualStatus = 'resolving';
} else if (hasFailureReason && failureReason) {
text = failureReason;
} else if (requestVisualStatus === 'pending') {
@@ -116,6 +132,7 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
};
};
/** The badges on a card: a download linked to a request shows both, once it is complete only its own. */
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
if (item.kind === 'download' && item.visualStatus === 'complete') {
return [getDownloadBadge(item)];
@@ -132,8 +149,57 @@ const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[]
return [getDownloadBadge(item)];
};
const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
/** A review stamp as "Mar 1", or undefined when it is missing or unreadable. */
const formatReviewDate = (value: string | null | undefined): string | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
return undefined;
}
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(parsed);
};
/** An admin note for display: an automatic one loses its "[auto] " marker. */
const formatAdminNote = (note: string): string =>
isAutomaticNote(note) ? stripAutomaticMarker(note) : note;
/**
* Who declined a rejected request and when, with the note, for admins. The reviewer is only sent
* to admin views; a decline the automation made says so instead of naming the account.
*/
const buildDeclinedLine = (item: ActivityItem): string | undefined => {
const record = item.requestRecord;
if (!record || record.status !== 'rejected') {
return undefined;
}
// Fork: a decline the automation made says so, instead of naming the account it acted through.
const automatic = isAutomaticNote(item.adminNote);
let who = 'Declined';
if (automatic) {
who = 'Declined automatically';
} else if (record.reviewer_username) {
who = `Declined by ${record.reviewer_username}`;
}
const parts = [who];
const date = formatReviewDate(record.reviewed_at);
if (date) {
parts.push(date);
}
const line = parts.join(' · ');
return item.adminNote ? `${line}: "${formatAdminNote(item.adminNote)}"` : line;
};
/**
* The quoted note under a request card: the declined line for admins, the user's note while it is
* pending, or the admin note once it is decided. Undefined when there is nothing to say.
*/
const buildRequestNoteLine = (item: ActivityItem, isAdmin: boolean): string | undefined => {
const requestStatus = item.requestRecord?.status;
if (isAdmin && requestStatus === 'rejected') {
return buildDeclinedLine(item);
}
if (item.requestNote && (requestStatus === 'pending' || item.visualStatus === 'pending')) {
return `"${item.requestNote}"`;
}
@@ -144,11 +210,14 @@ const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
item.visualStatus === 'rejected' ||
item.visualStatus === 'fulfilled')
) {
return `"${item.adminNote}"`;
return isAutomaticNote(item.adminNote)
? `Automatic: ${formatAdminNote(item.adminNote)}`
: `"${item.adminNote}"`;
}
return undefined;
};
/** The action buttons a card offers, by what the item is, its status and who is looking. */
const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[] => {
if (item.kind === 'download' && item.downloadBookId) {
const canRetry = item.downloadRetryAvailable === true;
@@ -215,10 +284,14 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
}
if (item.visualStatus === 'rejected' && isAdmin) {
return [
{ kind: 'request-reopen', requestId: item.requestId },
{ kind: 'request-dismiss', requestId: item.requestId },
];
const actions: ActivityCardAction[] = [{ kind: 'request-reopen', requestId: item.requestId }];
if (item.hiddenInRejected === true) {
actions.push({ kind: 'request-unhide', requestId: item.requestId });
} else if (item.hiddenInRejected === false) {
actions.push({ kind: 'request-hide', requestId: item.requestId });
}
actions.push({ kind: 'request-dismiss', requestId: item.requestId });
return actions;
}
if (
@@ -236,7 +309,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
export const buildActivityCardModel = (item: ActivityItem, isAdmin: boolean): ActivityCardModel => {
return {
badges: buildBadges(item, isAdmin),
noteLine: buildRequestNoteLine(item),
noteLine: buildRequestNoteLine(item, isAdmin),
actions: buildActions(item, isAdmin),
};
};
@@ -0,0 +1,107 @@
import { isRetryingAutomatically } from './activityAutomation';
import type { ActivityItem } from './activityTypes';
export type ActivityCategoryKey =
| 'needs_review'
| 'auto_retry'
| 'in_progress'
| 'complete'
| 'failed';
export type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history' | 'rejected';
/** The section header for a category; the no-match one reads differently for admins. */
export const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
// Fork: nothing here is waiting for a person; the automatic search found no match it trusts.
return isAdmin ? 'No Match Yet' : 'Waiting';
}
if (key === 'auto_retry') {
return 'Retrying Automatically';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
/** One line under a section header that says what, if anything, is expected of the reader. */
export const getCategoryHint = (key: ActivityCategoryKey, isAdmin: boolean): string | undefined => {
if (!isAdmin) {
return undefined;
}
if (key === 'needs_review') {
return 'Searched automatically on every sync. Choose a release yourself, decline, or leave it.';
}
if (key === 'auto_retry') {
return 'A download failed and another copy is being tried. Nothing to do.';
}
return undefined;
};
/** The categories a tab shows, in order. History and Rejected are flat lists. */
export const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'history' || tab === 'rejected') {
return [];
}
return ['needs_review', 'auto_retry', 'in_progress', 'complete', 'failed'];
};
/** Which section an item belongs under, by whether it is a download or a request and its state. */
export const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return isRetryingAutomatically(item.requestRecord) ? 'auto_retry' : 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
@@ -1,7 +1,8 @@
import type { Book, RequestRecord, StatusData } from '../../types';
import { getDownloadsCount } from '../../types';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
import { buildTimingText, parseBackendTimestamp } from './activityTiming';
import type { ActivityItem, FormatPill, ActivityVisualStatus } from './activityTypes';
export type DownloadStatusKey = Extract<
keyof StatusData,
@@ -82,6 +83,42 @@ const getDownloadProgress = (
return undefined;
};
// Fork: audio file formats, to colour a pill when the content type is not given.
const AUDIO_FILE_FORMATS = new Set([
'mp3',
'm4b',
'm4a',
'aac',
'flac',
'ogg',
'opus',
'wav',
'aax',
]);
const formatKind = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill['kind'] | undefined => {
const type = contentType?.toLowerCase();
if (type?.includes('audio')) return 'audiobook';
if (type) return 'ebook';
const format = fileFormat?.toLowerCase();
if (!format) return undefined;
return AUDIO_FILE_FORMATS.has(format) ? 'audiobook' : 'ebook';
};
const buildFormatPill = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill | undefined => {
const kind = formatKind(contentType, fileFormat);
if (!kind) return undefined;
const label = fileFormat?.toUpperCase() || (kind === 'audiobook' ? 'Audiobook' : 'Ebook');
return { label, kind };
};
/** A download from the status payload as a card item, with its format pill and timing text. */
export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey): ActivityItem => {
const visualStatus = statusKeyToVisualStatus(statusKey);
const requestId =
@@ -91,12 +128,26 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
const downloadsCount = getDownloadsCount(book);
const downloadsText =
downloadsCount != null ? `${downloadsCount.toLocaleString()} downloads` : undefined;
const formatPill = buildFormatPill(
toOptionalText(book.content_type),
toOptionalText(book.format),
);
// Fork: a finished download says how long it took and how long ago it ended.
const timingText =
statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled'
? buildTimingText({
startedMs: toEpochMillis(book.added_time),
finishedMs: toEpochMillis(book.finished_at),
showDuration: statusKey === 'complete',
})
: undefined;
const metaLine = joinMetaParts([
toOptionalText(book.format)?.toUpperCase(),
formatPill ? undefined : toOptionalText(book.format)?.toUpperCase(),
toOptionalText(book.size),
toOptionalText(book.source_display_name) || toSourceLabel(book.source),
downloadsText,
toOptionalText(book.username),
timingText,
]);
const progress = getDownloadProgress(visualStatus, book.progress);
const statusDetail = toOptionalText(book.status_message);
@@ -110,6 +161,8 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
author: toText(book.author, 'Unknown author'),
preview: toOptionalText(book.preview),
metaLine,
formatPill,
reattached: book.reattached === true ? true : undefined,
statusLabel: STATUS_LABELS[visualStatus],
statusDetail,
progress,
@@ -138,24 +191,31 @@ const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityV
const buildRequestMetaLine = (
record: RequestRecord,
bookData: Record<string, unknown>,
_bookData: Record<string, unknown>,
releaseData: Record<string, unknown>,
viewerRole: 'user' | 'admin',
): string => {
// Fork: a request that has been delivered says how long ago. The migration backfills this
// stamp from SQLite's zone-free UTC created_at, so it is parsed as UTC, not local time.
const deliveredMs = parseBackendTimestamp(record.delivery_updated_at);
const delivered =
record.status === 'fulfilled' && record.delivery_state === 'complete' && deliveredMs > 0
? buildTimingText({ finishedMs: deliveredMs, showDuration: false, agoPrefix: 'delivered' })
: undefined;
if (record.request_level === 'book') {
const contentType = toOptionalText(record.content_type || bookData.content_type)?.toLowerCase();
const requestTypeLabel = contentType === 'audiobook' ? 'Audiobook request' : 'Book request';
// Fork: the card shows the format as a coloured pill, so the line only names the requester.
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
return joinMetaParts([requestTypeLabel, username]);
return joinMetaParts([username, delivered]);
}
const format = toOptionalText(releaseData.format)?.toUpperCase();
// Fork: the release format is shown as the card's pill.
const size = toOptionalText(releaseData.size);
const source = toSourceLabel(releaseData.source || record.source_hint);
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
const line = joinMetaParts([format, size, source, username]);
return line || joinMetaParts(['Release request', username]);
const line = joinMetaParts([size, source, username, delivered]);
return line || joinMetaParts(['Release request', username, delivered]);
};
export const requestToActivityItem = (
@@ -178,6 +238,10 @@ export const requestToActivityItem = (
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
preview: toOptionalText(bookData.preview) || toOptionalText(releaseData.preview),
metaLine: buildRequestMetaLine(record, bookData, releaseData, viewerRole),
formatPill: buildFormatPill(
toOptionalText(record.content_type || bookData.content_type),
record.request_level === 'release' ? toOptionalText(releaseData.format) : undefined,
),
statusLabel: STATUS_LABELS[visualStatus],
adminNote: toOptionalText(record.admin_note),
timestamp,
@@ -0,0 +1,99 @@
// Fork: how long a finished download took and how long ago it ended, as short text for the end of
// a card's meta line ("took 4m · 2h ago"). Text only, so the cards stay as quiet as they are.
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" with no zone: SQLite's CURRENT_TIMESTAMP shape.
const ZONE_FREE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
/**
* Epoch ms for a backend timestamp, or 0 when it is missing or unreadable. A value with no
* zone is UTC (SQLite's CURRENT_TIMESTAMP writes those); an explicit zone or offset is kept.
*/
export const parseBackendTimestamp = (value: string | null | undefined): number => {
if (!value) {
return 0;
}
const text = value.trim();
const parsed = Date.parse(ZONE_FREE_TIMESTAMP.test(text) ? `${text.replace(' ', 'T')}Z` : text);
return Number.isFinite(parsed) ? parsed : 0;
};
/** "45s", "4m", "1h 20m", "3d": the two most useful units, never more. */
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
// Round first, then pick the unit, so 59.6s is "1m" and not "60s".
const seconds = Math.max(Math.round(ms / SECOND), 1);
if (seconds < 60) {
return `${seconds}s`;
}
const totalMinutes = Math.round(ms / MINUTE);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
if (totalMinutes < 24 * 60) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
return `${Math.max(Math.floor(ms / DAY), 1)}d`;
};
/** "just now", "12m ago", "3h ago", "2d ago". */
export const formatAgo = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
if (ms < MINUTE) {
return 'just now';
}
if (ms < HOUR) {
return `${Math.floor(ms / MINUTE)}m ago`;
}
if (ms < DAY) {
return `${Math.floor(ms / HOUR)}h ago`;
}
return `${Math.floor(ms / DAY)}d ago`;
};
interface TimingInput {
/** When it started, epoch ms. 0 or missing when unknown. */
startedMs?: number;
/** When it ended, epoch ms. 0 or missing when unknown. */
finishedMs?: number;
/** Say how long it took (a completed download); an error or cancel only says how long ago. */
showDuration: boolean;
/** Lead the ago text, e.g. "delivered" for a request. */
agoPrefix?: string;
now?: number;
}
/** The text for the end of the meta line, or undefined when nothing is known. */
export const buildTimingText = ({
startedMs = 0,
finishedMs = 0,
showDuration,
agoPrefix,
now = Date.now(),
}: TimingInput): string | undefined => {
if (finishedMs <= 0) {
return undefined;
}
const parts: string[] = [];
if (showDuration && startedMs > 0 && finishedMs >= startedMs) {
const took = formatDuration(finishedMs - startedMs);
if (took) {
parts.push(`took ${took}`);
}
}
const ago = formatAgo(now - finishedMs);
if (ago) {
parts.push(agoPrefix ? `${agoPrefix} ${ago}` : ago);
}
return parts.length > 0 ? parts.join(' · ') : undefined;
};
@@ -14,6 +14,11 @@ export type ActivityVisualStatus =
| 'fulfilled'
| 'rejected';
export interface FormatPill {
label: string;
kind: 'audiobook' | 'ebook';
}
export interface ActivityItem {
id: string;
kind: ActivityKind;
@@ -44,4 +49,10 @@ export interface ActivityItem {
requestLevel?: 'book' | 'release';
requestNote?: string;
requestRecord?: RequestRecord;
// Fork: format shown as a pill coloured by kind, e.g. EPUB in the ebook colour.
formatPill?: FormatPill;
// Fork: the download picked up work that was already under way (shown as a pill).
reattached?: boolean;
// Fork Rejected view only: whether the admin hid this rejected request there.
hiddenInRejected?: boolean;
}
+2 -2
View File
@@ -280,7 +280,7 @@ interface UseActivityResult {
prefetchActivityHistory: () => void;
refreshActivitySnapshot: () => Promise<void>;
refreshHistoryIfLoaded: () => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => void;
resetActivity: () => void;
handleActivityHistoryLoadMore: () => void;
handleRequestDismiss: (requestId: number) => void;
@@ -366,7 +366,7 @@ export const useActivity = ({
}, [isAuthenticated, resetActivityHistory]);
const handleActivityTabChange = useCallback(
(tab: 'all' | 'downloads' | 'requests' | 'history') => {
(tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => {
if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) {
return;
}
@@ -0,0 +1,78 @@
import { useCallback, useRef, useState } from 'react';
import { requestToActivityItem } from '../components/activity/activityMappers';
import type { ActivityItem } from '../components/activity/activityTypes';
import {
listHiddenRejectedRequestIds,
listRejectedAdminRequests,
setRejectedRequestHidden,
} from '../services/api';
interface UseRejectedRequestsResult {
rejectedItems: ActivityItem[];
rejectedLoading: boolean;
loadRejected: () => Promise<void>;
setRejectedHidden: (requestId: number, hidden: boolean) => Promise<void>;
}
/**
* Rejected requests for the admin Rejected view. Unlike the activity snapshot this includes
* requests that were cleared from the list, which is where most of them are. Each item says
* whether the admin hid it from this view (its own state, not the activity list's clear).
* Loaded from the events that change it, like History.
*/
export const useRejectedRequests = (): UseRejectedRequestsResult => {
const [rejectedItems, setRejectedItems] = useState<ActivityItem[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
const latestLoad = useRef(0);
/** Fetch the rejected requests and hidden ids; a stale load is dropped and an error empties the list. */
const loadRejected = useCallback(async () => {
latestLoad.current += 1;
const loadId = latestLoad.current;
setRejectedLoading(true);
try {
const [records, hiddenIds] = await Promise.all([
listRejectedAdminRequests(),
listHiddenRejectedRequestIds(),
]);
if (loadId !== latestLoad.current) {
return;
}
const hidden = new Set(hiddenIds);
const items = records.map((record) => {
const item = requestToActivityItem(record, 'admin');
item.hiddenInRejected = hidden.has(record.id);
return item;
});
items.sort((a, b) => b.timestamp - a.timestamp);
setRejectedItems(items);
} catch (error) {
console.error('Loading rejected requests failed:', error);
if (loadId === latestLoad.current) {
setRejectedItems([]);
}
} finally {
if (loadId === latestLoad.current) {
setRejectedLoading(false);
}
}
}, []);
/** Hide or show one request in the view; the item is left as it was when the call fails. */
const setRejectedHidden = useCallback(async (requestId: number, hidden: boolean) => {
try {
await setRejectedRequestHidden(requestId, hidden);
} catch (error) {
console.error('Changing hidden state failed:', error);
return;
}
setRejectedItems((current) =>
current.map((item) =>
item.requestId === requestId ? Object.assign({}, item, { hiddenInRejected: hidden }) : item,
),
);
}, []);
return { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden };
};
+19
View File
@@ -709,6 +709,25 @@ export const rejectAdminRequest = async (
});
};
export const listRejectedAdminRequests = async (): Promise<RequestRecord[]> => {
return fetchJSON<RequestRecord[]>(`${API.adminRequests}?status=rejected`);
};
/** Ids of the rejected requests hidden from the admin Rejected view; empty when the field is missing. */
export const listHiddenRejectedRequestIds = async (): Promise<number[]> => {
const response = await fetchJSON<{ request_ids: number[] }>(
`${API.adminRequests}/rejected-hidden`,
);
return Array.isArray(response.request_ids) ? response.request_ids : [];
};
export const setRejectedRequestHidden = async (id: number, hidden: boolean): Promise<void> => {
await fetchJSON(`${API.adminRequests}/${encodeURIComponent(String(id))}/rejected-hidden`, {
method: 'POST',
body: JSON.stringify({ hidden }),
});
};
export const reopenAdminRequest = async (
id: number,
body: RejectAdminRequestBody = {},
@@ -16,6 +16,18 @@ const makeItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
...overrides,
});
const rejectedActionKinds = (hiddenInRejected?: boolean) =>
buildActivityCardModel(
makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 47,
downloadBookId: undefined,
hiddenInRejected,
}),
true,
).actions.map((action) => action.kind);
describe('activityCardModel', () => {
it('shows ownership in badge text for admin pending requests', () => {
const model = buildActivityCardModel(
@@ -30,7 +42,7 @@ describe('activityCardModel', () => {
);
expect(model.badges.length).toBe(1);
expect(model.badges[0]?.text).toBe('Needs review · testuser');
expect(model.badges[0]?.text).toBe('No match yet');
});
it('keeps pending label for requester-side pending requests', () => {
@@ -273,4 +285,152 @@ describe('activityCardModel', () => {
expect(model.actions.map((action) => action.kind)).toEqual(['request-dismiss']);
});
it('tells an admin who declined a request and when', () => {
const record = {
id: 45,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'My Dog Thinks It Is a Cat' },
release_data: null,
note: null,
admin_note: 'already in the library',
reviewed_by: 1,
reviewed_at: '2026-09-22T05:31:12Z',
created_at: '2026-09-20T03:37:29Z',
updated_at: '2026-09-22T05:31:12Z',
username: 'reader',
reviewer_username: 'admin@example.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 45,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined by admin@example\.com · .+: "already in the library"$/);
const requesterLine = buildActivityCardModel(item, false).noteLine;
expect(requesterLine).toBe('"already in the library"');
});
it("says a decline the automation made was automatic, not a person's", () => {
const record = {
id: 46,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'Sauron Defeated' },
release_data: null,
note: null,
admin_note: '[auto] No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
reviewer_username: 'rob@martin-up.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 46,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined automatically · .+: "No audiobook found on Audible/);
expect(adminLine).not.toContain('rob@martin-up.com');
expect(adminLine).not.toContain('[auto]');
expect(buildActivityCardModel(item, false).noteLine).toBe(
'Automatic: No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
);
});
it('says an approval the automation made was automatic', () => {
const item = makeItem({
kind: 'request',
visualStatus: 'fulfilled',
requestId: 47,
downloadBookId: undefined,
adminNote: '[auto] Already in the library.',
requestRecord: {
id: 47,
user_id: 7,
status: 'fulfilled' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'X' },
release_data: null,
note: null,
admin_note: '[auto] Already in the library.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
},
});
expect(buildActivityCardModel(item, true).noteLine).toBe('Automatic: Already in the library.');
});
it('says a pending request the automation reopened is retrying automatically', () => {
const record = {
id: 48,
user_id: 7,
status: 'pending' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'The Big Bad Wolf' },
release_data: null,
note: null,
admin_note: '[auto] retry batch 5 of 7 after the cancelled-download reopen',
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-16T22:40:00Z',
username: 'reader',
last_failure_reason: 'Peer transfer failed',
};
const model = buildActivityCardModel(
makeItem({ kind: 'request', visualStatus: 'pending', requestId: 48, requestRecord: record }),
true,
);
expect(model.badges[0]?.text).toBe('Retrying automatically');
expect(model.badges[0]?.visualStatus).not.toBe('error');
});
it('offers hide or unhide on rejected cards only in the Rejected view', () => {
expect(rejectedActionKinds()).toEqual(['request-reopen', 'request-dismiss']);
expect(rejectedActionKinds(false)).toEqual([
'request-reopen',
'request-hide',
'request-dismiss',
]);
expect(rejectedActionKinds(true)).toEqual([
'request-reopen',
'request-unhide',
'request-dismiss',
]);
});
});
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from '../components/activity/activityAutomation';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
} from '../components/activity/activityCategories';
import type { ActivityItem } from '../components/activity/activityTypes';
import type { RequestRecord } from '../types';
const pendingItem = (extra: Partial<RequestRecord>): ActivityItem => ({
id: 'request-1',
kind: 'request',
visualStatus: 'pending',
title: 'T',
author: 'A',
metaLine: '',
statusLabel: 'Pending',
timestamp: 0,
requestRecord: {
id: 1,
user_id: 7,
status: 'pending',
source_hint: null,
content_type: 'audiobook',
request_level: 'book',
policy_mode: 'request_book',
book_data: { title: 'T' },
release_data: null,
note: null,
admin_note: null,
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T00:00:00Z',
updated_at: '2026-10-01T00:00:00Z',
username: 'reader',
...extra,
},
});
describe('automatic notes', () => {
it('recognises and strips the marker', () => {
expect(isAutomaticNote('[auto] Already in the library.')).toBe(true);
expect(isAutomaticNote(' [AUTO] x')).toBe(true);
expect(isAutomaticNote('a person wrote this')).toBe(false);
expect(isAutomaticNote(null)).toBe(false);
expect(stripAutomaticMarker('[auto] Already in the library.')).toBe('Already in the library.');
});
});
describe('isRetryingAutomatically', () => {
it('is true for a pending request the automation reopened', () => {
expect(isRetryingAutomatically({ status: 'pending', admin_note: '[auto] retry batch 5' })).toBe(
true,
);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Retrying after a 20-minute cooldown (failed)',
}),
).toBe(true);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Interrupted by a restart',
}),
).toBe(true);
});
it('is false when nothing was reopened, or the request is not pending', () => {
expect(isRetryingAutomatically({ status: 'pending' })).toBe(false);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'All download sources failed',
}),
).toBe(false);
expect(isRetryingAutomatically({ status: 'rejected', admin_note: '[auto] retry' })).toBe(false);
expect(isRetryingAutomatically(null)).toBe(false);
});
});
describe('request categories', () => {
it('puts a reopened request under Retrying Automatically', () => {
expect(getActivityCategory(pendingItem({ admin_note: '[auto] retry batch 5' }))).toBe(
'auto_retry',
);
});
it('keeps a request nothing matched under No Match Yet', () => {
expect(getActivityCategory(pendingItem({}))).toBe('needs_review');
expect(
getActivityCategory(pendingItem({ last_failure_reason: 'All download sources failed' })),
).toBe('needs_review');
});
it('labels the sections for what they mean', () => {
expect(getCategoryLabel('needs_review', true)).toBe('No Match Yet');
expect(getCategoryLabel('needs_review', false)).toBe('Waiting');
expect(getCategoryLabel('auto_retry', true)).toBe('Retrying Automatically');
});
it('says what is expected of an admin, and nothing to anyone else', () => {
expect(getCategoryHint('needs_review', true)).toContain('Searched automatically');
expect(getCategoryHint('auto_retry', true)).toContain('Nothing to do');
expect(getCategoryHint('needs_review', false)).toBeUndefined();
expect(getCategoryHint('complete', true)).toBeUndefined();
});
it('shows the retrying section on the requests and all tabs but not under downloads', () => {
expect(getVisibleCategoryOrder('requests')).toContain('auto_retry');
expect(getVisibleCategoryOrder('all')).toContain('auto_retry');
expect(getVisibleCategoryOrder('downloads')).not.toContain('auto_retry');
expect(getVisibleCategoryOrder('history')).toEqual([]);
});
});
+129 -6
View File
@@ -85,7 +85,8 @@ describe('activityMappers.downloadToActivityItem', () => {
expect(item.kind).toBe('download');
expect(item.visualStatus).toBe('queued');
expect(item.statusLabel).toBe('Queued');
expect(item.metaLine).toBe('EPUB · 3 MB · Direct Download · alice');
expect(item.metaLine).toBe('3 MB · Direct Download · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.progress).toBe(5);
expect(item.progressAnimated).toBe(true);
expect(item.timestamp).toBe(123);
@@ -115,7 +116,8 @@ describe('activityMappers.downloadToActivityItem', () => {
it('omits empty meta parts cleanly', () => {
const item = downloadToActivityItem(makeBook({ format: 'epub', size: undefined }), 'error');
expect(item.metaLine).toBe('EPUB');
expect(item.metaLine).toBe('');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
});
});
@@ -139,7 +141,8 @@ describe('activityMappers.requestToActivityItem', () => {
expect(item.kind).toBe('request');
expect(item.visualStatus).toBe('pending');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr · alice');
expect(item.metaLine).toBe('2 MB · Prowlarr · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.requestId).toBe(42);
expect(item.requestLevel).toBe('release');
expect(item.requestNote).toBe('please add this');
@@ -157,7 +160,7 @@ describe('activityMappers.requestToActivityItem', () => {
'user',
);
expect(item.metaLine).toBe('Book request');
expect(item.metaLine).toBe('');
});
it('maps audiobook book-level request with audiobook label', () => {
@@ -171,7 +174,7 @@ describe('activityMappers.requestToActivityItem', () => {
'admin',
);
expect(item.metaLine).toBe('Audiobook request · alice');
expect(item.metaLine).toBe('alice');
});
it('maps rejected requests with admin note', () => {
@@ -189,7 +192,7 @@ describe('activityMappers.requestToActivityItem', () => {
it('does not append username to meta line for user viewer role', () => {
const item = requestToActivityItem(makeRequest(), 'user');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr');
expect(item.metaLine).toBe('2 MB · Prowlarr');
});
});
@@ -221,3 +224,123 @@ describe('activityMappers.linkedDownloadIdForRequest', () => {
expect(linkedDownloadIdForRequest(item, new Map([[42, 'dl_abc']]))).toBeNull();
});
});
describe('activityMappers format pill', () => {
it('colours an audio file as an audiobook even without a content type', () => {
const item = downloadToActivityItem(
{ id: 'a1', title: 'Young Zaphod', author: 'Douglas Adams', format: 'm4b' },
'complete',
);
expect(item.formatPill).toEqual({ label: 'M4B', kind: 'audiobook' });
});
it('uses the content type over the file format', () => {
const item = downloadToActivityItem(
{
id: 'a2',
title: 'Young Zaphod',
author: 'Douglas Adams',
format: 'mp3',
content_type: 'audiobook',
},
'complete',
);
expect(item.formatPill).toEqual({ label: 'MP3', kind: 'audiobook' });
});
it('labels a book-level request by its type', () => {
const item = requestToActivityItem(
makeRequest({ request_level: 'book', content_type: 'audiobook', release_data: null }),
'admin',
);
expect(item.formatPill).toEqual({ label: 'Audiobook', kind: 'audiobook' });
});
});
describe('activityMappers reattached pill', () => {
it('carries the flag for a download that picked up work already under way', () => {
const item = downloadToActivityItem(
{ id: 'r1', title: 'Cross Down', author: 'James Patterson', reattached: true },
'downloading',
);
expect(item.reattached).toBe(true);
});
it('leaves it off for an ordinary download', () => {
const item = downloadToActivityItem(
{ id: 'r2', title: 'Cross Down', author: 'James Patterson' },
'downloading',
);
expect(item.reattached).toBeUndefined();
});
});
describe('activityMappers timing', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
it('adds how long a completed download took and how long ago to the meta line', () => {
const item = downloadToActivityItem(
{
id: 't1',
title: 'Cross Down',
author: 'James Patterson',
format: 'm4b',
added_time: nowSeconds - 2 * 3600 - 240,
finished_at: nowSeconds - 2 * 3600,
},
'complete',
);
expect(item.metaLine).toContain('took 4m');
expect(item.metaLine).toContain('2h ago');
});
it('only says how long ago for a failed download', () => {
const item = downloadToActivityItem(
{
id: 't2',
title: 'X',
author: 'Y',
added_time: nowSeconds - 600,
finished_at: nowSeconds - 300,
},
'error',
);
expect(item.metaLine).toContain('5m ago');
expect(item.metaLine).not.toContain('took');
});
it('adds nothing for a download that is still running', () => {
const item = downloadToActivityItem(
{ id: 't3', title: 'X', author: 'Y', added_time: nowSeconds - 600, finished_at: null },
'downloading',
);
expect(item.metaLine).not.toContain('ago');
});
it('says a request was delivered how long ago', () => {
const item = requestToActivityItem(
makeRequest({
status: 'fulfilled',
delivery_state: 'complete',
delivery_updated_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(),
}),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('reads a migrated zone-free delivery stamp as UTC', () => {
const delivered = new Date(Date.now() - 3 * 3600 * 1000);
const stamp = delivered.toISOString().slice(0, 19).replace('T', ' ');
const item = requestToActivityItem(
makeRequest({ status: 'fulfilled', delivery_state: 'complete', delivery_updated_at: stamp }),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('says nothing about delivery for a request that is still waiting', () => {
const item = requestToActivityItem(makeRequest({ status: 'pending' }), 'admin');
expect(item.metaLine).not.toContain('delivered');
});
});
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
buildTimingText,
formatAgo,
formatDuration,
parseBackendTimestamp,
} from '../components/activity/activityTiming';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe('formatDuration', () => {
it('uses the two most useful units', () => {
expect(formatDuration(500)).toBe('1s');
expect(formatDuration(45 * SECOND)).toBe('45s');
expect(formatDuration(4 * MINUTE)).toBe('4m');
expect(formatDuration(HOUR + 20 * MINUTE)).toBe('1h 20m');
expect(formatDuration(3 * HOUR)).toBe('3h');
expect(formatDuration(3 * DAY + 5 * HOUR)).toBe('3d');
});
it('rounds before picking the unit, so it never says 60 of anything', () => {
expect(formatDuration(59.6 * SECOND)).toBe('1m');
expect(formatDuration(59.6 * MINUTE)).toBe('1h');
expect(formatDuration(HOUR + 59 * MINUTE + 45 * SECOND)).toBe('2h');
expect(formatDuration(23 * HOUR + 59 * MINUTE + 45 * SECOND)).toBe('1d');
});
it('says nothing for a value that makes no sense', () => {
expect(formatDuration(-1)).toBe('');
expect(formatDuration(Number.NaN)).toBe('');
});
});
describe('formatAgo', () => {
it('reads as a relative time', () => {
expect(formatAgo(10 * SECOND)).toBe('just now');
expect(formatAgo(12 * MINUTE)).toBe('12m ago');
expect(formatAgo(3 * HOUR + 40 * MINUTE)).toBe('3h ago');
expect(formatAgo(2 * DAY)).toBe('2d ago');
});
it('says nothing for a time in the future', () => {
expect(formatAgo(-5 * MINUTE)).toBe('');
});
});
describe('buildTimingText', () => {
const now = 1_000_000_000_000;
it('says how long a completed download took and how long ago it ended', () => {
const text = buildTimingText({
startedMs: now - 2 * HOUR - 4 * MINUTE,
finishedMs: now - 2 * HOUR,
showDuration: true,
now,
});
expect(text).toBe('took 4m · 2h ago');
});
it('only says how long ago when the duration is not wanted', () => {
expect(
buildTimingText({
startedMs: now - 3 * HOUR,
finishedMs: now - HOUR,
showDuration: false,
now,
}),
).toBe('1h ago');
});
it('leads the ago text with a prefix when one is given', () => {
expect(
buildTimingText({
finishedMs: now - 3 * HOUR,
showDuration: false,
agoPrefix: 'delivered',
now,
}),
).toBe('delivered 3h ago');
});
it('skips the duration when the start is unknown or after the end', () => {
expect(buildTimingText({ finishedMs: now - MINUTE * 5, showDuration: true, now })).toBe(
'5m ago',
);
expect(
buildTimingText({ startedMs: now, finishedMs: now - MINUTE * 5, showDuration: true, now }),
).toBe('5m ago');
});
it('says nothing without a finish time', () => {
expect(buildTimingText({ startedMs: now - HOUR, showDuration: true, now })).toBeUndefined();
expect(buildTimingText({ finishedMs: 0, showDuration: true, now })).toBeUndefined();
});
});
describe('parseBackendTimestamp', () => {
it('reads a zone-free SQLite stamp as UTC', () => {
expect(parseBackendTimestamp('2026-03-01 12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
});
it('keeps an explicit zone or offset', () => {
expect(parseBackendTimestamp('2026-03-01T12:00:00Z')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00+02:00')).toBe(Date.UTC(2026, 2, 1, 10));
});
it('is 0 for nothing or nonsense', () => {
expect(parseBackendTimestamp(undefined)).toBe(0);
expect(parseBackendTimestamp(null)).toBe(0);
expect(parseBackendTimestamp('')).toBe(0);
expect(parseBackendTimestamp('yesterday')).toBe(0);
});
});
+3
View File
@@ -57,6 +57,8 @@ export interface Book {
titles_by_language?: Record<string, string>;
username?: string;
retry_available?: boolean;
finished_at?: number | null; // When a finished download ended, epoch seconds
reattached?: boolean; // Picked up work already under way, e.g. a torrent the client had
downloads?: number;
extra?: Record<string, unknown>;
}
@@ -265,6 +267,7 @@ export interface RequestRecord {
created_at: string;
updated_at: string;
username?: string;
reviewer_username?: string;
}
export interface QueuedDownloadResult {
@@ -126,3 +126,16 @@ def test_the_download_payload_leaves_a_missing_cover_alone():
)["preview"]
is None
)
def test_the_download_payload_says_when_a_finished_download_ended():
with tempfile.TemporaryDirectory() as tmpdir:
_user_db, service = _service(tmpdir)
_record(service, request_id=None, title="Book")
active = DownloadHistoryService.to_download_payload(service.get_by_task_id("release-1"))
service.finalize_download(task_id="release-1", final_status="complete", status_message="x")
done = DownloadHistoryService.to_download_payload(service.get_by_task_id("release-1"))
assert active["finished_at"] is None
assert isinstance(done["finished_at"], float)
assert done["finished_at"] >= done["added_time"]
+25 -1
View File
@@ -1,6 +1,6 @@
"""Tests for shared request helper normalization utilities."""
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.request_helpers import normalize_optional_text, populate_request_reviewers
def test_normalize_optional_text_trims_strings():
@@ -12,3 +12,27 @@ def test_normalize_optional_text_returns_none_for_empty_or_non_string_values():
assert normalize_optional_text(None) is None
assert normalize_optional_text(123) is None
assert normalize_optional_text(False) is None
class _Users:
def __init__(self):
self.lookups = 0
def get_user(self, *, user_id):
self.lookups += 1
return {1: {"username": "admin@example.com"}}.get(user_id)
def test_populate_request_reviewers_names_the_reviewer_once_per_user():
users = _Users()
rows = [{"reviewed_by": 1}, {"reviewed_by": 1}, {"reviewed_by": None}, {"reviewed_by": 99}]
populate_request_reviewers(rows, users)
assert [row["reviewer_username"] for row in rows] == [
"admin@example.com",
"admin@example.com",
"",
"",
]
assert users.lookups == 2
+42
View File
@@ -1393,6 +1393,48 @@ class TestRequestRoutes:
}
assert f"request:{request_id}" not in hidden_keys
def test_admin_can_hide_a_rejected_request_and_list_hidden(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
created = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={"title": "Half a Book", "author": "Someone", "content_type": "ebook"},
)
request_id = created["id"]
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
as_user = client.post(
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
)
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
while_pending = client.post(
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
)
main_module.user_db.update_request(request_id, status="rejected")
bad_payload = client.post(
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": "yes"}
)
hide = client.post(
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
)
listed = client.get("/api/admin/requests/rejected-hidden")
show = client.post(
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": False}
)
listed_after = client.get("/api/admin/requests/rejected-hidden")
assert as_user.status_code == 403
assert while_pending.status_code == 409
assert bad_payload.status_code == 400
assert hide.status_code == 200
assert request_id in listed.json["request_ids"]
assert show.status_code == 200
assert request_id not in listed_after.json["request_ids"]
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
+109
View File
@@ -2155,3 +2155,112 @@ def test_reopen_rejected_request_returns_none_for_unknown_or_other_states(user_d
book_data=_book_data(),
)
assert user_db.reopen_rejected_request(created["id"]) is None
def test_rejected_request_can_be_hidden_and_shown_again(user_db):
_alice, _admin, created = _rejected_request(user_db)
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is True
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is True
assert user_db.list_hidden_rejected_request_ids() == [created["id"]]
assert user_db.set_rejected_request_hidden(created["id"], hidden=False) is True
assert user_db.list_hidden_rejected_request_ids() == []
def test_only_a_rejected_request_can_be_hidden(user_db):
alice = user_db.create_user(username="alice")
created = create_request(
user_db,
user_id=alice["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=_book_data(),
)
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is False
assert user_db.list_hidden_rejected_request_ids() == []
def test_reopen_clears_hidden_so_a_later_rejection_starts_visible(user_db):
_alice, admin, created = _rejected_request(user_db)
user_db.set_rejected_request_hidden(created["id"], hidden=True)
reopen_request(user_db, request_id=created["id"], admin_user_id=admin["id"])
reject_request(user_db, request_id=created["id"], admin_user_id=admin["id"])
assert user_db.list_hidden_rejected_request_ids() == []
def _fulfilled_request(user_db, *, delivery_state):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
created = create_request(
user_db,
user_id=alice["id"],
source_hint="prowlarr",
content_type="audiobook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data=_release_data(),
)
fulfil_request(
user_db,
request_id=created["id"],
admin_user_id=admin["id"],
queue_release=lambda *a, **kw: (True, None),
)
user_db.update_request(created["id"], delivery_state=delivery_state)
return created["id"], admin
@pytest.mark.parametrize("delivery_state", ["cancelled", "error"])
def test_reopen_request_reopens_a_fulfilled_request_whose_download_failed(user_db, delivery_state):
request_id, admin = _fulfilled_request(user_db, delivery_state=delivery_state)
reopened = reopen_request(
user_db, request_id=request_id, admin_user_id=admin["id"], admin_note="[auto] retry"
)
assert reopened["status"] == "pending"
assert reopened["delivery_state"] == "none"
assert reopened["release_data"] is None
assert reopened["admin_note"] == "[auto] retry"
assert reopened["last_failure_reason"] == "[auto] retry"
# The next automatic search skips the release that failed, as when the queue reopens it.
assert reopened["book_data"]["failed_releases"] == [
{"source": "prowlarr", "source_id": "release-123"}
]
def test_reopen_request_writes_the_note_in_the_reopening_write(user_db, monkeypatch):
"""A failed-download reopen never lands without its note: there is no second update."""
request_id, admin = _fulfilled_request(user_db, delivery_state="error")
def no_second_write(*_args, **_kwargs):
msg = "admin_note must be written by reopen_failed_request"
raise AssertionError(msg)
monkeypatch.setattr(user_db, "update_request", no_second_write)
reopened = reopen_request(
user_db, request_id=request_id, admin_user_id=admin["id"], admin_note="Try again"
)
assert reopened["status"] == "pending"
assert reopened["admin_note"] == "Try again"
@pytest.mark.parametrize("delivery_state", ["complete", "queued"])
def test_reopen_request_refuses_a_fulfilled_request_that_is_delivered_or_in_flight(
user_db, delivery_state
):
request_id, admin = _fulfilled_request(user_db, delivery_state=delivery_state)
with pytest.raises(RequestServiceError) as exc_info:
reopen_request(user_db, request_id=request_id, admin_user_id=admin["id"])
assert exc_info.value.code == "stale_transition"
assert user_db.get_request(request_id)["status"] == "fulfilled"
+23
View File
@@ -1028,6 +1028,29 @@ class TestDownloadRequests:
assert reopened["status"] == "pending"
assert reopened["last_failure_reason"] is None
def test_reopen_failed_request_writes_the_admin_note_and_keeps_it_when_not_given(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
status="fulfilled",
delivery_state="error",
admin_note="Approved from browse mode",
)
kept = user_db.reopen_failed_request(created["id"])
assert kept is not None
assert kept["admin_note"] == "Approved from browse mode"
user_db.update_request(created["id"], status="fulfilled", delivery_state="error")
noted = user_db.reopen_failed_request(created["id"], admin_note=" Try again ")
assert noted is not None
assert noted["admin_note"] == "Try again"
def test_count_pending_requests(self, user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
+54
View File
@@ -0,0 +1,54 @@
"""A live queue entry says when it ended, so a card has its timing before history catches up."""
from __future__ import annotations
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.queue import BookQueue
from shelfmark.download.orchestrator import _task_to_dict
def _finished_task(status: QueueStatus) -> tuple[BookQueue, DownloadTask]:
"""A task driven to the given terminal status through the queue."""
queue = BookQueue()
task = DownloadTask(task_id="t", source="direct_download", title="Book")
assert queue.add(task) is True
assert queue.get_next() is not None
queue.update_status("t", QueueStatus.DOWNLOADING)
queue.update_status("t", status)
return queue, task
def test_a_task_is_stamped_when_it_first_ends() -> None:
_queue, task = _finished_task(QueueStatus.COMPLETE)
assert task.finished_at is not None
assert task.finished_at >= task.added_time
def test_the_stamp_is_set_before_the_terminal_hook_runs() -> None:
queue = BookQueue()
task = DownloadTask(task_id="t", source="direct_download", title="Book")
seen: list[float | None] = []
queue.set_terminal_status_hook(lambda _id, _status, hooked: seen.append(hooked.finished_at))
assert queue.add(task) is True
assert queue.get_next() is not None
queue.update_status("t", QueueStatus.ERROR)
assert seen and seen[0] is not None
def test_a_requeued_task_is_running_again_so_it_has_no_end() -> None:
queue, task = _finished_task(QueueStatus.ERROR)
assert queue.enqueue_existing("t") is True
assert task.finished_at is None
def test_the_stamp_reaches_the_status_payload() -> None:
_queue, task = _finished_task(QueueStatus.CANCELLED)
assert (
_task_to_dict(task, current_status=QueueStatus.CANCELLED)["finished_at"] == task.finished_at
)
+78
View File
@@ -0,0 +1,78 @@
"""A download that picks up work already under way says so, so nobody wonders why it started at 40%."""
from __future__ import annotations
from pathlib import Path
from threading import Event
from unittest.mock import MagicMock, patch
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.download.clients import DownloadState, DownloadStatus
from shelfmark.download.orchestrator import _task_to_dict
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
_STATUS = DownloadStatus(
progress=40.0, state=DownloadState.DOWNLOADING, message=None, complete=False, file_path=None
)
_DONE = DownloadStatus(
progress=100.0, state=DownloadState.COMPLETE, message=None, complete=True, file_path="/x/b"
)
def _run(*, existing: bool, finished: bool = False) -> DownloadTask:
"""Run one Prowlarr download against a mocked client that may already hold the torrent.
With ``finished`` the client's copy is already complete, so the handler copies the file
instead of joining the poll loop.
"""
client = MagicMock()
client.name = "qbittorrent"
found = ("dl", _DONE if finished else _STATUS) if existing else None
client.find_existing.return_value = found
client.add_download.return_value = "dl"
cancel = Event()
def get_status(_id: str) -> DownloadStatus:
cancel.set()
return _STATUS
client.get_status.side_effect = get_status
release = {"protocol": "torrent", "magnetUrl": "magnet:?xt=urn:btih:abc123"}
task = DownloadTask(task_id="t", source="prowlarr", title="Book")
with (
patch("shelfmark.release_sources.prowlarr.handler.get_release", return_value=release),
patch("shelfmark.release_sources.prowlarr.handler.get_client", return_value=client),
patch("shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", 0.01),
patch.object(
ProwlarrHandler, "_wait_for_completed_path", return_value=(Path("/x/b"), None)
),
patch.object(ProwlarrHandler, "_handle_completed_file", return_value="/lib/b"),
):
ProwlarrHandler().download(
task=task,
cancel_flag=cancel,
progress_callback=lambda _p: None,
status_callback=lambda *_a: None,
)
return task
def test_a_task_that_joins_a_torrent_the_client_had_is_reattached() -> None:
assert _run(existing=True).reattached is True
def test_a_task_whose_torrent_the_client_had_finished_is_reattached_too() -> None:
assert _run(existing=True, finished=True).reattached is True
def test_a_task_that_adds_its_own_torrent_is_not() -> None:
assert _run(existing=False).reattached is False
@pytest.mark.parametrize("flag", [True, False])
def test_the_flag_reaches_the_status_payload(flag: bool) -> None:
task = DownloadTask(task_id="t", source="prowlarr", title="Book", reattached=flag)
assert _task_to_dict(task, current_status=QueueStatus.DOWNLOADING)["reattached"] is flag