mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 21:58:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d97110f01 | ||
|
|
bb13fae0bb | ||
|
|
e575ff0fcb | ||
|
|
be803ed5ca | ||
|
|
76d7df306f | ||
|
|
efc0f3df6b | ||
|
|
f9d0dbfcc4 | ||
|
|
e828bdbe9c | ||
|
|
a01e700c81 | ||
|
|
8c06862ec7 | ||
|
|
c38f4c6522 | ||
|
|
371ac75954 | ||
|
|
318b7483fe | ||
|
|
68165e1730 | ||
|
|
b1a1f46096 | ||
|
|
149f32b955 | ||
|
|
0bb54ac8f8 | ||
|
|
985a54765d | ||
|
|
afb9bf1670 | ||
|
|
4c3ce3355a | ||
|
|
7c90afca9e | ||
|
|
e8ec6cc715 | ||
|
|
eb9ee093de | ||
|
|
b246c6617b | ||
|
|
95e01913b4 | ||
|
|
eb93a32f4b | ||
|
|
2cb1fb4ac6 | ||
|
|
fe7073fdf6 | ||
|
|
3af8df177a | ||
|
|
99d842b381 | ||
|
|
fa42f357d2 | ||
|
|
9d8e7eaad2 | ||
|
|
ed0777a2e0 | ||
|
|
622f6ac677 | ||
|
|
fa28bc2185 | ||
|
|
2254e2bf93 | ||
|
|
384adc4c75 | ||
|
|
f06585aa01 | ||
|
|
9ca1981d00 | ||
|
|
c6685feba5 | ||
|
|
34c9e6dbd7 | ||
|
|
d1e959758d | ||
|
|
d41fa98749 | ||
|
|
0a7e114681 |
No files matched your search
@@ -77,7 +77,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
# Runners are ephemeral, so without this every layer is rebuilt on every
|
||||
# run: the pinned Chromium install, the dependency syncs, the driver
|
||||
# run: the Chromium install, the dependency syncs, the driver
|
||||
# setup. Scoped per target so the two images do not evict each other. A cache
|
||||
# export error (ignore-error defaults to false) should not fail a finished publish.
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -25,15 +25,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v3
|
||||
uses: github/codeql-action/init@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v3
|
||||
uses: github/codeql-action/autobuild@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v3
|
||||
uses: github/codeql-action/analyze@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Jules PR Review (forks)
|
||||
# Fork PRs get no secrets under pull_request, so they are reviewed here under
|
||||
# pull_request_target, which runs this file from main with the repo's secrets.
|
||||
# That is safe only while nothing here checks out or runs the PR's code: the
|
||||
# action reads the diff through the API. Upstream jules-pr-reviewer refuses
|
||||
# this event, hence the calibrain fork.
|
||||
# The jules-forks environment has no required reviewers on purpose, so fork
|
||||
# pushes are reviewed straight away; adding reviewers there (Settings →
|
||||
# Environments) would make each run wait for a maintainer's approval.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
# Not jules-review-: the same-repo workflow's skipped runs would cancel this one
|
||||
group: jules-review-fork-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# Drafts are skipped here rather than by the action, so they never start a job
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: jules-forks
|
||||
deployment: false
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
checks: write
|
||||
steps:
|
||||
- uses: calibrain/jules-pr-reviewer@v0.0.3
|
||||
with:
|
||||
jules_api_key: ${{ secrets.JULES_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
skip_forks: false
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Jules PR Review
|
||||
# Reviews PRs whose branch lives in this repo. Fork PRs get no secrets under
|
||||
# pull_request and Dependabot runs get only Dependabot secrets, so
|
||||
# JULES_API_KEY would be empty for both; jules-pr-reviewer-forks.yml reviews
|
||||
# fork PRs instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: jules-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
checks: write
|
||||
steps:
|
||||
- uses: calibrain/jules-pr-reviewer@v0.0.3
|
||||
with:
|
||||
jules_api_key: ${{ secrets.JULES_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+4
-16
@@ -28,7 +28,7 @@ RUN npm run build
|
||||
# than copied into the image. A COPY here would land ~24 MB in a `base` layer that
|
||||
# every published image inherits, and a later `rm` cannot take it back out again --
|
||||
# a RUN adds a layer, it does not rewrite the one underneath.
|
||||
FROM ghcr.io/astral-sh/uv:0.13.6@sha256:af22a52f5eafef3d78e551599d405c02df61919d18028f0cea8eda93bc9986d0 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.13.7@sha256:96f40d3904aad8d0bd010539001d36c8e2188b981e1334471a90c4a1d93bb490 AS uv
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14.9-slim@sha256:35caba83af2fe6da9a5d922f9b0156f21d1246a48278c58a08bd253303ba42e8 AS base
|
||||
@@ -148,28 +148,16 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
FROM base AS shelfmark
|
||||
|
||||
# --- Chromium (PINNED to 156.0.8078.73) ---
|
||||
ARG CHROMIUM_VERSION=156.0.8078.73-1~deb13u1
|
||||
ARG CHROMIUM_SNAPSHOT=20261029T020000Z
|
||||
|
||||
RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-security/${CHROMIUM_SNAPSHOT}/ trixie-security main" \
|
||||
> /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
|
||||
apt-get update -o Acquire::Retries=5 && \
|
||||
# --- Chromium (Debian's current build from trixie-security) ---
|
||||
RUN apt-get update -o Acquire::Retries=5 && \
|
||||
apt-get install -y --no-install-recommends -o Acquire::Retries=5 \
|
||||
# For dumb display
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
chromium=${CHROMIUM_VERSION} \
|
||||
chromium-common=${CHROMIUM_VERSION} \
|
||||
chromium \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk \
|
||||
# For RAR extraction
|
||||
unrar-free && \
|
||||
# Keep apt from "upgrading" chromium past the pin inside derived images
|
||||
printf 'Package: chromium chromium-common\nPin: version %s\nPin-Priority: 1001\n' "${CHROMIUM_VERSION}" \
|
||||
> /etc/apt/preferences.d/chromium-pin && \
|
||||
rm /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
|
||||
# Create symlink so rarfile library can find unrar
|
||||
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
|
||||
# Cleanup APT cache
|
||||
|
||||
@@ -4,4 +4,5 @@ Guides for adding to Shelfmark.
|
||||
|
||||
- [Release Sources Plugin Guide](release-sources-plugin-guide.md): add a source that searches for releases and a handler that downloads them
|
||||
- [Plugin Settings Guide](plugin-settings.md): add settings tabs and fields for a plugin to the Settings UI
|
||||
- [Adding Routes](routes.md): where a new API route goes, and how a route module gets what it needs
|
||||
- [Metadata Providers](../../shelfmark/metadata_providers/README.md): add a metadata provider
|
||||
@@ -0,0 +1,3 @@
|
||||
# Adding Routes
|
||||
|
||||
New API routes go in their own module under `shelfmark/core/`, named for the group (`stats_routes.py`, `settings_routes.py`), as a `register_<group>_routes(app, ...)` function that `main.py` calls once. Anything the routes need that lives in `main.py` or is shared with another group, such as `login_required`, the auth mode or the user database, is passed in as a parameter, so a route module never imports `main`. When a test patches one of those, pass a callable that is evaluated per request, the way `register_stats_routes` does. Helpers that belong to one group stay in its module. Please don't add `@app.route` handlers to `main.py` itself: two PRs that each add a route there land in the same stretch of the file and conflict, where two route modules only share the one register call. `shelfmark/core/stats_routes.py` is the smallest example to copy. A few older routes are still in `main.py` and are being moved out one group at a time, see [Discussion #1536](https://github.com/calibrain/shelfmark/discussions/1536).
|
||||
@@ -1127,7 +1127,7 @@ Custom label for the OIDC sign-in button on the login page.
|
||||
| `HARDCOVER_SYNC_CONTENT_TYPE` | Content type for synced requests. "Ebooks and audiobooks" creates one request of each per shelf book; every request is checked against its own library and downloaded with its own format guard and source priority. | string (choice) | `audiobook` |
|
||||
| `HARDCOVER_SYNC_INTERVAL` | How often the scheduled sync runs (minimum 1 minute). | number | `6` |
|
||||
| `HARDCOVER_SYNC_INTERVAL_UNIT` | Whether the sync interval is measured in minutes or hours. | string (choice) | `hours` |
|
||||
| `AUTO_DOWNLOAD_ENABLED` | Auto-approve and auto-download matching releases for synced requests. | boolean | `false` |
|
||||
| `AUTO_DOWNLOAD_ENABLED` | Auto-approve and auto-download matching releases for Hardcover requests. Another user's request is only approved this way where their request policy already lets them download directly; the rest wait for an admin. | boolean | `false` |
|
||||
| `AUTO_DOWNLOAD_SOURCE_PRIORITY` | Drag to set which release sources are tried first for audiobook requests. The first source with a strict match wins. Disabled sources are skipped. | JSON array | _empty list_ |
|
||||
| `AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY` | Drag to set which release sources are tried first for ebook requests. The first source with a strict match wins. Disabled sources are skipped. | JSON array | _empty list_ |
|
||||
| `AUTO_DOWNLOAD_MIN_SEEDERS` | Skip torrent releases with fewer seeders than this. | number | `1` |
|
||||
@@ -1204,7 +1204,7 @@ Whether the sync interval is measured in minutes or hours.
|
||||
|
||||
**Enable automatic downloads**
|
||||
|
||||
Auto-approve and auto-download matching releases for synced requests.
|
||||
Auto-approve and auto-download matching releases for Hardcover requests. Another user's request is only approved this way where their request policy already lets them download directly; the rest wait for an admin.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
@@ -1904,9 +1904,12 @@ Automatically retry search without category filtering if no results are found
|
||||
| `SLSKD_ISOLATE_DOWNLOADS` | Download into a folder of its own inside slskd's downloads folder (needs slskd 0.26 or newer). Other apps using the same slskd cannot mix files with it or lose track of it when they clear slskd's list of finished transfers, and Shelfmark confirms a download by the files on disk. | boolean | `true` |
|
||||
| `SLSKD_DESTINATION_PREFIX` | The folder, inside slskd's downloads folder, that holds one folder per download. It must not climb out of slskd's downloads folder. | string | `shelfmark` |
|
||||
| `SLSKD_KEEP_COMPLETED` | Leave the finished files (and their transfer records) in slskd after Shelfmark copies them to your library, so they stay shared with other Soulseek users. Turn off to delete them once imported. | boolean | `true` |
|
||||
| `SLSKD_FINISH_LEFTOVERS` | A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended. | boolean | `true` |
|
||||
| `SLSKD_CLEAN_LEFTOVERS_DAYS` | Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting. | number | `0` |
|
||||
| `SLSKD_SEARCH_TIMEOUT` | How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. | number | `15` |
|
||||
| `SLSKD_RESPONSE_LIMIT` | Stop a search early once this many peers have answered | number | `100` |
|
||||
| `SLSKD_QUEUE_TIMEOUT_MINUTES` | Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. | number | `60` |
|
||||
| `SLSKD_CHAT_AUTOPROVE` | Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running. | boolean | `false` |
|
||||
| `SLSKD_REMOVE_COMPLETED` | Clear the transfer from slskd's download list after Shelfmark imports it. Applies when each download does not get its own folder. | boolean | `true` |
|
||||
|
||||
<details>
|
||||
@@ -1975,6 +1978,25 @@ Leave the finished files (and their transfer records) in slskd after Shelfmark c
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `SLSKD_FINISH_LEFTOVERS`
|
||||
|
||||
**Import finished folders an interruption left behind**
|
||||
|
||||
A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `SLSKD_CLEAN_LEFTOVERS_DAYS`
|
||||
|
||||
**Remove leftover download folders after (days)**
|
||||
|
||||
Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `0`
|
||||
- **Constraints:** min: 0, max: 365
|
||||
|
||||
#### `SLSKD_SEARCH_TIMEOUT`
|
||||
|
||||
**Search Wait (seconds)**
|
||||
@@ -2005,6 +2027,15 @@ Give up when the peer has not started sending within this long. Set to 0 to wait
|
||||
- **Default:** `60`
|
||||
- **Constraints:** min: 0, max: 1440
|
||||
|
||||
#### `SLSKD_CHAT_AUTOPROVE`
|
||||
|
||||
**Answer anti-leech chat challenges**
|
||||
|
||||
Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `SLSKD_REMOVE_COMPLETED`
|
||||
|
||||
**Remove completed transfers from slskd**
|
||||
@@ -2311,7 +2342,7 @@ How long to keep cached search results before they expire.
|
||||
| `QBITTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
|
||||
| `QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
|
||||
| `QBITTORRENT_TAG` | Tag(s) to assign to qBittorrent downloads. Leave empty for no tags. | string (comma-separated) | _empty list_ |
|
||||
| `TRANSMISSION_URL` | URL of your Transmission instance (use https:// for TLS) | string | _none_ |
|
||||
| `TRANSMISSION_URL` | URL of your Transmission instance (use https:// for TLS). Without a port, https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091 | string | _none_ |
|
||||
| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ |
|
||||
| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ |
|
||||
| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` |
|
||||
@@ -2343,6 +2374,7 @@ How long to keep cached search results before they expire.
|
||||
| `TORRENT_MIN_SEEDERS` | Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. Only qBittorrent reports seed counts. | number | `1` |
|
||||
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
|
||||
| `TORRENT_STALL_TIMEOUT_MINUTES` | How long a torrent can go without any progress before Shelfmark cancels it. Applies from when the torrent starts and restarts each time it moves. Raise it for slow swarms or magnets that take a while to find peers, or lower it to give up on dead torrents sooner. | number | `15` |
|
||||
| `STALE_TORRENT_HOURS` | Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported. | number | `6` |
|
||||
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
|
||||
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
|
||||
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
|
||||
@@ -2516,7 +2548,7 @@ Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.
|
||||
|
||||
**Transmission URL**
|
||||
|
||||
URL of your Transmission instance (use https:// for TLS)
|
||||
URL of your Transmission instance (use https:// for TLS). Without a port, https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
@@ -2804,6 +2836,16 @@ How long a torrent can go without any progress before Shelfmark cancels it. Appl
|
||||
- **Default:** `15`
|
||||
- **Constraints:** min: 5, max: 60
|
||||
|
||||
#### `STALE_TORRENT_HOURS`
|
||||
|
||||
**Remove dead torrents after (hours)**
|
||||
|
||||
Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `6`
|
||||
- **Constraints:** min: 0, max: 72
|
||||
|
||||
#### `PROWLARR_USENET_CLIENT`
|
||||
|
||||
**Usenet Client**
|
||||
|
||||
@@ -21,6 +21,8 @@ A book is skipped when:
|
||||
|
||||
When enabled, each pending request that came from Hardcover is searched across your release sources in the priority order configured for its content type (audiobook requests use the audiobook list, ebook requests the ebook list). The first source that yields a **strict match** wins, and the best candidate from that source is queued through the normal request fulfilment path, so it appears in the activity view and download history exactly like a request an admin approved.
|
||||
|
||||
Automatic downloads act for an admin, so they only approve what the request's owner could have downloaded without asking. Synced requests belong to an admin and are always searched. A request another user made is searched only on the release sources where their request policy (including any per-user override) resolves to **Download**, and only while requests are turned on for them; with requests off they download directly anyway. A request whose policy needs approval, or blocks that content type, is left pending for an admin, untouched, like any other request awaiting review. A failed download is only reopened for a retry on the same terms.
|
||||
|
||||
A release is a strict match only when all of the following hold:
|
||||
|
||||
- at least 85% of the significant words in the book's title appear in the release title. Numbers from 1 to 20 count as the same word however they are written, so *Part II*, *Part Two* and *Part 2* agree, while *Part One* never stands in for *Part Two*;
|
||||
@@ -41,7 +43,7 @@ A request whose book turns out to be in your library already is closed, not left
|
||||
|
||||
Hardcover allows a short burst of lookups and then answers 429. Each pass paces its book lookups and backs off when told to, following `Retry-After`. A lookup that still fails counts as an error for that request, not as "book not found", and the request is tried again next pass.
|
||||
|
||||
A download that fails, or is interrupted (a restart while it was queued, say), does not stay that way forever. Once the request has sat there for **Retry failed downloads after (days)** (7 by default), it goes back to pending and the next pass searches again. Each pass reopens at most five, oldest first, so a backlog is spread out instead of hitting your sources at once. An interrupted download may pick the same release again, since a torrent already in your client is joined instead of added twice. A download that itself failed, or that Shelfmark's stall timer cancelled, has its release remembered and skipped next time, and the request is given up on after three failed retries. Requests you or an admin cancelled are never retried, and neither are rejected ones: both are decisions. Set the value to 0 to turn retries off.
|
||||
A download that fails, or is interrupted (a restart while it was queued, say), does not stay that way forever. Once the request has sat there for **Retry failed downloads after (days)** (7 by default), it goes back to pending and the next pass searches again. Each pass reopens at most five, oldest first, so a backlog is spread out instead of hitting your sources at once. An interrupted download may pick the same release again, since a torrent already in your client is joined instead of added twice. A download that itself failed, or that Shelfmark's stall timer cancelled, has its release remembered and skipped next time (not when the download client could not be reached or refused Shelfmark's login: that says nothing about the release), and the request is given up on after three failed retries. Requests you or an admin cancelled are never retried, and neither are rejected ones: both are decisions. Set the value to 0 to turn retries off.
|
||||
|
||||
A request that no source has a match for is not searched every cycle for ever. After three passes in a row with no match it is searched once a day, then every two days, and no further apart than **Longest wait between searches for an unmatched request (days)** (3 by default, 0 searches every unmatched request every cycle). A match, a search that failed (which says nothing about the book) or **Sync now** starts it over.
|
||||
**Trying another release sooner.** Set **Try another release after a failed download (minutes)** (off by default) and a download that fails, or that is given up on (stalled, or no seeders when **Give Up With No Seeders** is on), does not wait for the days above. Between sync cycles Shelfmark looks for such requests, reopens them after that many minutes and searches again, for those requests only. The release that failed is skipped, and a request is still given up on after three failed attempts.
|
||||
@@ -74,7 +76,7 @@ The check **fails closed** here: if an enabled library cannot be read and has no
|
||||
| `HARDCOVER_SYNC_CONTENT_TYPE` | Request as | Audiobooks | `Audiobooks`, `Ebooks`, or `Ebooks and audiobooks` — the last creates one request of each per shelf book, each checked against its own library |
|
||||
| `HARDCOVER_SYNC_INTERVAL` | Sync interval | 6 | Combined with the unit below |
|
||||
| `HARDCOVER_SYNC_INTERVAL_UNIT` | Interval unit | Hours | Minutes or hours. The effective interval never drops below 60 seconds |
|
||||
| `AUTO_DOWNLOAD_ENABLED` | Enable automatic downloads | Off | Auto-approve and queue strict matches for every pending Hardcover request, synced or made by a user with Request Book. The download belongs to the user who asked, and admins see every user's requests |
|
||||
| `AUTO_DOWNLOAD_ENABLED` | Enable automatic downloads | Off | Auto-approve and queue strict matches for pending Hardcover requests: synced ones, and a user's own only on the sources where their request policy already lets them download directly; the rest wait for an admin. The download belongs to the user who asked, and admins see every user's requests |
|
||||
| `AUTO_DOWNLOAD_SOURCE_PRIORITY` | Audiobook source priority | All usable sources | Drag to order; used for audiobook requests. Sources you disable here, or that are not configured, are skipped |
|
||||
| `AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY` | Ebook source priority | All usable sources | Same, for ebook requests |
|
||||
| `AUTO_DOWNLOAD_MIN_SEEDERS` | Minimum seeders (torrents) | 1 | Torrent releases below this are ignored |
|
||||
@@ -108,7 +110,7 @@ Only one run happens at a time. If a scheduled cycle is in progress, **Sync now*
|
||||
|
||||
**Sync adds nothing and the log says "no Hardcover token configured"** — set **Hardcover API Token** on this tab, or configure the token on the Hardcover metadata provider. A sync without a usable token counts as an error and creates no requests.
|
||||
|
||||
**Requests appear but nothing downloads** — check **Enable automatic downloads** is on, that the source-priority list for the request's content type contains at least one configured source, and look for `no strict match` lines in the log. Lowering **Minimum seeders** helps on quiet trackers; loosening the title or author match is deliberately not an option.
|
||||
**Requests appear but nothing downloads** — check **Enable automatic downloads** is on, that the source-priority list for the request's content type contains at least one configured source, and look for `no strict match` lines in the log. A request from another user is only searched on the sources where their policy allows a direct download (or, with requests turned off for them, on every source); with no such source it stays pending for an admin and is not searched. Lowering **Minimum seeders** helps on quiet trackers; loosening the title or author match is deliberately not an option.
|
||||
|
||||
**Books you own keep being added** — enable the library check for the format you request (see [Library Check](library-check.md)) and use its test button. A library that cannot be read shows up in the log as a skipped cycle.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Shelfmark writes its log to the container output, so `docker logs shelfmark` sho
|
||||
|
||||
Set `DEBUG=true` on the container and restart it. A **DEBUG** button then appears in the interface. It generates a zip with logs, configuration and environment details, with sensitive values redacted. Have a look through it before you post it publicly, then attach it when you open an issue.
|
||||
|
||||
The full image also records the browser's screen during a bypass while Debug mode is on. ffmpeg isn't in the image, so the container installs it in the background at startup, which needs network access. Bypasses that run before it finishes aren't recorded. The log says when the install succeeds, fails or gives up after 120 seconds. In non-root mode it can't install anything, so the log warns that bypasses won't be recorded and everything else works as normal.
|
||||
|
||||
## Reporting an issue
|
||||
|
||||
Open an issue on [GitHub](https://github.com/calibrain/shelfmark/issues) and fill in the bug report template. A description of what you expected, the steps to reproduce it and the debug bundle are what make a problem findable.
|
||||
@@ -69,6 +69,10 @@ Admins can override the default ebook/audiobook modes and request rules for indi
|
||||
|
||||
---
|
||||
|
||||
## Clearing the activity list
|
||||
|
||||
**Clear Completed** in the activity list clears finished downloads and their requests, and also finished requests that never had a download, such as a request closed because the book was already in the library. Pending and rejected requests are left alone.
|
||||
|
||||
## Requests
|
||||
|
||||
The request system controls whether users can download directly or need admin approval first.
|
||||
@@ -94,6 +98,7 @@ Each content type (ebook, audiobook) has a default mode that sets the baseline:
|
||||
| Request Rules | Per-source overrides (see below) | None |
|
||||
| Max Pending Requests Per User | Open request limit per user | 20 |
|
||||
| Allow Notes on Requests | Let users attach a note when submitting | On |
|
||||
| Allow book requests alongside downloads | Let users who can download also request a book without choosing a release, for a book nobody has released yet or one no source has. The request waits for an admin, and the user gets the Requests tab to follow or cancel it. Admins can always do this | Off |
|
||||
|
||||
### Request Rules
|
||||
|
||||
@@ -115,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.
|
||||
@@ -498,6 +498,50 @@ else
|
||||
fi
|
||||
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
|
||||
|
||||
# Debug mode's screen recording needs ffmpeg, which the image leaves out because nothing
|
||||
# else uses it. This runs in the background so a slow or silent mirror can't hold up
|
||||
# startup. A mirror can only hang the download, and killing that leaves nothing half
|
||||
# installed. dpkg then works from what's on disk, and the next start's --configure -a
|
||||
# clears up if a restart or the time limit cuts it off.
|
||||
install_ffmpeg() {
|
||||
local limit="${FFMPEG_INSTALL_TIMEOUT:-120}" status=0
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
umask 0022
|
||||
timeout -k 10 "$limit" dpkg --configure -a || status=$?
|
||||
if [ "$status" = 0 ]; then
|
||||
# No && after update: a partial index failure should not rule out the install.
|
||||
timeout -k 10 "$limit" sh -c 'apt-get update -o Acquire::Retries=3
|
||||
apt-get install -y --no-install-recommends --download-only ffmpeg' || status=$?
|
||||
fi
|
||||
if [ "$status" = 0 ]; then
|
||||
timeout -k 10 "$limit" apt-get install -y --no-install-recommends --no-download ffmpeg || status=$?
|
||||
fi
|
||||
case "$status" in
|
||||
0)
|
||||
if ffmpeg -version >/dev/null 2>&1; then
|
||||
echo "ffmpeg installed; bypasses will be recorded from now on."
|
||||
else
|
||||
echo "Could not install ffmpeg (apt finished but ffmpeg does not run); bypasses will not be recorded."
|
||||
fi
|
||||
;;
|
||||
124 | 137) echo "Timed out installing ffmpeg after ${limit}s; bypasses will not be recorded." ;;
|
||||
*) echo "Could not install ffmpeg (exit $status); bypasses will not be recorded." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Ask the app rather than reading $DEBUG: the Debug Mode setting lives in the config file
|
||||
# and needs a restart, so this is where it takes effect.
|
||||
INSTALL_FFMPEG="false"
|
||||
if [ "$USING_EXTERNAL_BYPASSER" != "true" ] && ! ffmpeg -version >/dev/null 2>&1 &&
|
||||
"$PYTHON_BIN" -c 'from shelfmark.config.env import DEBUG; raise SystemExit(0 if DEBUG else 1)' 2>/dev/null; then
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
echo "Debug mode is on but ffmpeg is not installed and non-root mode cannot install it; bypasses will not be recorded."
|
||||
else
|
||||
echo "Debug mode is on: installing ffmpeg in the background; bypasses before it finishes will not be recorded."
|
||||
INSTALL_FFMPEG="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If DEBUG and not using an external bypass
|
||||
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
set +e
|
||||
@@ -589,4 +633,13 @@ echo "Setting umask to $UMASK_VALUE"
|
||||
umask $UMASK_VALUE
|
||||
|
||||
stop_file_logging
|
||||
# Started only now because stop_file_logging waits for the log pipe to close, which a
|
||||
# background job holding it would delay. The outer subshell leaves it to dumb-init to reap.
|
||||
if [ "$INSTALL_FFMPEG" = "true" ]; then
|
||||
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
|
||||
( (install_ffmpeg 2>&1 | tee -a "$LOG_FILE") </dev/null & )
|
||||
else
|
||||
( (install_ffmpeg 2>&1) </dev/null & )
|
||||
fi
|
||||
fi
|
||||
exec_as_target_user env HOME="$RUNTIME_HOME" $command
|
||||
+5
-5
@@ -21,22 +21,22 @@ dependencies = [
|
||||
"rarfile",
|
||||
"qbittorrent-api>=2026.10.1",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.9.1,<1.10",
|
||||
"authlib>=1.9.5,<1.10",
|
||||
# Authlib validates ID tokens with joserfc and raises its errors, so the OIDC routes
|
||||
# catch joserfc's InvalidClaimError. authlib.jose is deprecated and goes in Authlib 2.0.
|
||||
"joserfc>=1.6.8",
|
||||
"joserfc>=1.8.0",
|
||||
"apprise>=2.1.0",
|
||||
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
|
||||
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
|
||||
"httpx[http2]>=0.28.1",
|
||||
"publicsuffixlist>=1.1.2.20261023",
|
||||
"publicsuffixlist>=1.1.2.20261105",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.56.4",
|
||||
"seleniumbase==4.57.1",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -47,7 +47,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.17.3",
|
||||
"ruff==0.17.4",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ Some of the additional options available in Settings:
|
||||
- **Webshare.cz** - Search Webshare.cz by file name and download ebooks and audiobooks with your Webshare account (a VIP account is recommended for full speed)
|
||||
- **Direct Download mirrors** - Supply your own Anna's Archive mirror URLs; Auto mode tries them in the order listed. The `annas-archive.is` domain does not currently work as a source — use `annas-archive.gl` instead (checked August 2026; mirror availability changes)
|
||||
- **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results
|
||||
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release
|
||||
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release. Some uploaders block downloads until you type a word to them in a private message; turn on **Answer anti-leech chat challenges** and Shelfmark types it for you, at most once per user every 12 hours. It reads every unread private message in slskd and marks a conversation it answered as read, so leave it off if you also read slskd's messages yourself
|
||||
- **Library Link** - Add links to your book library (Calibre-Web or Grimmory) and your audiobook library (Audiobookshelf, Plex) in the UI header
|
||||
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
|
||||
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
|
||||
@@ -324,6 +324,20 @@ make restart # Restart container
|
||||
|
||||
The frontend dev server proxies to the backend on port 8084.
|
||||
|
||||
|
||||
## Sponsors
|
||||
|
||||
Code review on Shelfmark's pull requests is sponsored by [CodeRabbit](https://coderabbit.link/calibrain).
|
||||
|
||||
<a href="https://coderabbit.link/calibrain">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.coderabbit.ai/content/assets/white-typemark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://www.coderabbit.ai/content/assets/orange-typemark.svg">
|
||||
<img src="https://www.coderabbit.ai/content/assets/orange-typemark.svg" alt="CodeRabbit" width="240">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
@@ -1499,6 +1500,11 @@ async def _close_websocket_connection(conn: Any) -> None:
|
||||
def _start_ffmpeg_recording(display: str) -> None:
|
||||
"""Start FFmpeg screen recording for debug mode."""
|
||||
global DISPLAY
|
||||
# Debug mode can be on when ffmpeg is not installed. A recording is a diagnostic, so
|
||||
# going without one must not fail the bypass it was meant to help diagnose.
|
||||
if shutil.which("ffmpeg") is None:
|
||||
logger.warning("ffmpeg is not installed; no recording for this bypass.")
|
||||
return
|
||||
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(UTC).strftime("%y%m%d-%H%M%S")
|
||||
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
|
||||
|
||||
@@ -141,7 +141,8 @@ def hardcover_sync_settings() -> list[SettingsField]:
|
||||
title="Automatic Downloads",
|
||||
description=(
|
||||
"When enabled, pending Hardcover requests (synced from a shelf, or made by a "
|
||||
"user with Request Book) are auto-approved and downloaded from the "
|
||||
"user whose request policy lets them download directly) are auto-approved and "
|
||||
"downloaded from the "
|
||||
"first source in the matching list below that yields a strict title/author/"
|
||||
"format match for the request's content type (ebook formats for ebook "
|
||||
"requests, audiobook formats for audiobook requests). If no confident match "
|
||||
@@ -151,7 +152,11 @@ def hardcover_sync_settings() -> list[SettingsField]:
|
||||
CheckboxField(
|
||||
key="AUTO_DOWNLOAD_ENABLED",
|
||||
label="Enable automatic downloads",
|
||||
description="Auto-approve and auto-download matching releases for Hardcover requests.",
|
||||
description=(
|
||||
"Auto-approve and auto-download matching releases for Hardcover requests. "
|
||||
"Another user's request is only approved this way where their request "
|
||||
"policy already lets them download directly; the rest wait for an admin."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
OrderableListField(
|
||||
|
||||
@@ -450,4 +450,16 @@ def users_settings() -> list[SettingsField]:
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD",
|
||||
label="Allow book requests alongside downloads",
|
||||
description=(
|
||||
"Let users who can download also request a book without choosing a release, for "
|
||||
"a book nobody has released yet or one no source has. The request waits for an "
|
||||
"admin. Admins can always do this."
|
||||
),
|
||||
default=False,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
@@ -28,9 +28,11 @@ 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
|
||||
from shelfmark.core.status_messages import explain_buckets, explain_request_rows
|
||||
from shelfmark.core.viewer_redaction import (
|
||||
is_public_download_id,
|
||||
match_public_download_id,
|
||||
@@ -335,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:
|
||||
@@ -646,6 +650,8 @@ def register_activity_routes(
|
||||
user_id=actor.owner_scope,
|
||||
)
|
||||
emit_request_updates(updated_requests)
|
||||
# Plain wording for what the list shows, after the sync above has used the real text.
|
||||
explain_buckets(status)
|
||||
request_rows = _list_visible_requests(
|
||||
user_db,
|
||||
is_admin=actor.is_admin,
|
||||
@@ -659,6 +665,7 @@ def register_activity_routes(
|
||||
if f"request:{request_id}" in hidden_item_keys:
|
||||
continue
|
||||
visible_request_rows.append(row)
|
||||
visible_request_rows = explain_request_rows(visible_request_rows)
|
||||
|
||||
if not actor.is_admin:
|
||||
# Redacted only now: the delivery-state sync above matches on real task ids.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""App setup that does not depend on any route: the log filter, the persisted secret key with
|
||||
the session cookie settings, and the baseline security headers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
FLASK_SECRET_KEY_MIN_BYTES = 32
|
||||
|
||||
|
||||
class LogNoiseFilter(logging.Filter):
|
||||
"""Filter out routine status endpoint requests and WebSocket upgrade errors to reduce log noise.
|
||||
|
||||
WebSocket upgrade errors are benign - Flask-SocketIO automatically falls back to polling transport.
|
||||
The error occurs because Werkzeug's built-in server doesn't fully support WebSocket upgrades.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Return whether a log record should be emitted."""
|
||||
message = record.getMessage() if hasattr(record, "getMessage") else str(record.msg)
|
||||
|
||||
# Exclude GET /api/status requests (polling noise)
|
||||
if "GET /api/status" in message:
|
||||
return False
|
||||
|
||||
# Exclude WebSocket upgrade errors (benign - falls back to polling)
|
||||
if "write() before start_response" in message:
|
||||
return False
|
||||
|
||||
# Exclude the Error on request line that precedes WebSocket errors
|
||||
if record.levelno == logging.ERROR:
|
||||
if "Error on request:" in message:
|
||||
return False
|
||||
# Filter WebSocket-related AssertionError tracebacks
|
||||
if hasattr(record, "exc_info") and record.exc_info:
|
||||
exc_type, exc_value = record.exc_info[0], record.exc_info[1]
|
||||
if (
|
||||
exc_type
|
||||
and exc_type.__name__ == "AssertionError"
|
||||
and exc_value
|
||||
and "write() before start_response" in str(exc_value)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def configure_app_logging(app: Flask, app_logger: logging.Logger) -> None:
|
||||
"""Send Flask's and Werkzeug's logs through the app's handlers, minus the routine noise."""
|
||||
app.logger.handlers = app_logger.handlers
|
||||
app.logger.setLevel(app_logger.level)
|
||||
# Also handle Werkzeug's logger
|
||||
werkzeug_logger = logging.getLogger("werkzeug")
|
||||
werkzeug_logger.handlers = app_logger.handlers
|
||||
werkzeug_logger.setLevel(app_logger.level)
|
||||
# Add filter to suppress routine status endpoint polling logs and WebSocket upgrade errors
|
||||
werkzeug_logger.addFilter(LogNoiseFilter())
|
||||
|
||||
|
||||
def load_or_create_secret_key(config_dir: Path) -> bytes:
|
||||
"""Load a persisted Flask secret key from config, or create one."""
|
||||
secret_path = config_dir / ".flask_secret"
|
||||
|
||||
try:
|
||||
if secret_path.exists():
|
||||
secret_key = secret_path.read_bytes()
|
||||
if len(secret_key) >= FLASK_SECRET_KEY_MIN_BYTES:
|
||||
return secret_key
|
||||
logger.warning(
|
||||
"Invalid persisted Flask secret key at %s (length=%s). Regenerating.",
|
||||
secret_path,
|
||||
len(secret_key),
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read Flask secret key at %s: %s", secret_path, exc)
|
||||
|
||||
secret_key = os.urandom(64)
|
||||
try:
|
||||
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Created private, and an existing file made private before the key goes in, so the key
|
||||
# is never in a file others can read.
|
||||
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as secret_file:
|
||||
os.fchmod(secret_file.fileno(), 0o600)
|
||||
secret_file.write(secret_key)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to persist Flask secret key at %s. Sessions may reset on restart: %s",
|
||||
secret_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
return secret_key
|
||||
|
||||
|
||||
def configure_secret_key_and_session(
|
||||
app: Flask, config_dir: Path, *, cookie_secure: bool, cookie_name: str
|
||||
) -> None:
|
||||
"""Set the secret key and the session cookie settings."""
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config_dir),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=cookie_secure,
|
||||
SESSION_COOKIE_NAME=cookie_name,
|
||||
PERMANENT_SESSION_LIFETIME=604800, # 7 days in seconds
|
||||
)
|
||||
|
||||
|
||||
def register_security_headers(app: Flask) -> None:
|
||||
"""Add the baseline security headers to every response.
|
||||
|
||||
Call this at the place the hook should take in the ``after_request`` order, which runs
|
||||
the hooks in reverse.
|
||||
"""
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response: Response) -> Response:
|
||||
"""Add baseline security headers to every response."""
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:",
|
||||
)
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
|
||||
)
|
||||
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
|
||||
return response
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Request hooks for the two ways a request can be authenticated without logging in: the
|
||||
SHELFMARK_API_KEY credentials and the reverse proxy's identity headers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
|
||||
from flask import Response, g, jsonify, request, session
|
||||
|
||||
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.stats_routes import STATS_PATH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
"""Raise RuntimeError with the message, for raising inside a try block (ruff TRY301)."""
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def _proxy_default_is_admin(db: UserDB) -> bool:
|
||||
"""Role for a proxy user seen for the first time when no admin group is configured.
|
||||
|
||||
The first account ever provisioned is an admin so the instance is never left without
|
||||
one; later accounts follow PROXY_AUTH_DEFAULT_ROLE (default: user).
|
||||
"""
|
||||
if not db.has_admin():
|
||||
return True
|
||||
role = str(app_config.get("PROXY_AUTH_DEFAULT_ROLE", "user") or "user").strip().lower()
|
||||
return role == "admin"
|
||||
|
||||
|
||||
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_API_KEY_EXEMPT_PATHS = frozenset({"/api/health"})
|
||||
_READ_ONLY_KEY_PATHS = frozenset({STATS_PATH})
|
||||
|
||||
|
||||
def register_auth_middleware(
|
||||
app: Flask,
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
) -> None:
|
||||
"""Register the API-key and proxy-auth ``before_request`` hooks and the cookie-stripping
|
||||
``after_request`` hook.
|
||||
|
||||
Call this at the place the hooks should take in the hook order: ``before_request`` hooks run
|
||||
in registration order, ``after_request`` hooks in reverse. The API-key hook must come first
|
||||
because the proxy hook skips requests it already authenticated.
|
||||
"""
|
||||
|
||||
@app.before_request
|
||||
def api_key_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Authenticate requests that present the configured SHELFMARK_API_KEY.
|
||||
|
||||
Both Authorization: Bearer and X-Api-Key are checked, and either
|
||||
matching authenticates the request as an admin for this request only:
|
||||
any session cookie is ignored and none is written back. Checking both
|
||||
means a reverse proxy's own Authorization header never shadows an
|
||||
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
|
||||
tokens forwarded by reverse proxies keep working; the request then
|
||||
continues on the normal session path.
|
||||
"""
|
||||
if not request.path.startswith("/api/"):
|
||||
return None
|
||||
if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(
|
||||
_API_KEY_EXEMPT_PREFIXES
|
||||
):
|
||||
return None
|
||||
if not api_key_module.SHELFMARK_API_KEY and not api_key_module.SHELFMARK_API_KEY_READONLY:
|
||||
return None
|
||||
|
||||
candidates = api_key_module.extract_api_key_candidates(
|
||||
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
scopes = {api_key_module.key_scope(candidate) for candidate in candidates}
|
||||
scope = "admin" if "admin" in scopes else "readonly" if "readonly" in scopes else None
|
||||
if scope is None:
|
||||
return None
|
||||
if resolve_auth_mode() == "none":
|
||||
return None
|
||||
|
||||
# Mark the request as keyed before the lookup so the after-request cookie
|
||||
# reset also covers the error path below.
|
||||
g.api_key_auth = True
|
||||
|
||||
if scope == "readonly":
|
||||
# A read-only key is a dashboard credential: one GET endpoint, no session.
|
||||
g.api_key_scope = "readonly"
|
||||
if request.method != "GET" or request.path not in _READ_ONLY_KEY_PATHS:
|
||||
return jsonify({"error": "This API key is read-only"}), 403
|
||||
return None
|
||||
g.api_key_scope = "admin"
|
||||
|
||||
try:
|
||||
user_db = get_user_db()
|
||||
admin = user_db.get_first_admin() if user_db is not None else None
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("API key auth middleware error")
|
||||
return jsonify({"error": "Authentication error"}), 500
|
||||
|
||||
session.clear()
|
||||
session["user_id"] = admin["username"] if admin else "api"
|
||||
session["is_admin"] = True
|
||||
if admin:
|
||||
session["db_user_id"] = admin["id"]
|
||||
session.permanent = False
|
||||
# Identity is per request; never persist it as a cookie.
|
||||
session.modified = False
|
||||
return None
|
||||
|
||||
@app.before_request
|
||||
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Middleware to handle proxy authentication.
|
||||
|
||||
When AUTH_METHOD is set to "proxy", this middleware automatically
|
||||
authenticates users based on headers set by the reverse proxy.
|
||||
"""
|
||||
auth_mode = resolve_auth_mode()
|
||||
|
||||
# Only run for proxy auth mode
|
||||
if auth_mode != "proxy":
|
||||
return None
|
||||
|
||||
# A request already authenticated by SHELFMARK_API_KEY needs no proxy headers.
|
||||
if g.get("api_key_auth"):
|
||||
return None
|
||||
|
||||
# Skip for public endpoints that don't need auth
|
||||
if request.path == "/api/health":
|
||||
return None
|
||||
|
||||
def get_proxy_header(header_name: str) -> str | None:
|
||||
"""Resolve proxy auth values from headers with WSGI env fallbacks."""
|
||||
value = request.headers.get(header_name)
|
||||
if value:
|
||||
return value
|
||||
|
||||
env_key = f"HTTP_{header_name.upper().replace('-', '_')}"
|
||||
value = request.environ.get(env_key)
|
||||
if value:
|
||||
return value
|
||||
|
||||
# Some proxies set authenticated username in REMOTE_USER (not as a header).
|
||||
if header_name.lower().replace("_", "-") == "remote-user":
|
||||
return request.environ.get("REMOTE_USER")
|
||||
|
||||
return None
|
||||
|
||||
user_db = get_user_db()
|
||||
|
||||
try:
|
||||
user_header = (
|
||||
normalize_optional_text(app_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User"))
|
||||
or "X-Auth-User"
|
||||
)
|
||||
|
||||
# Extract username from proxy header
|
||||
username = get_proxy_header(user_header)
|
||||
|
||||
if not username:
|
||||
if request.path.startswith("/api/auth/"):
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Proxy auth enabled but no username found in header '%s'", user_header
|
||||
)
|
||||
return jsonify({"error": "Authentication required. Proxy header not set."}), 401
|
||||
|
||||
# Resolve admin role for proxy sessions.
|
||||
# If an admin group is configured, derive from groups header.
|
||||
# Otherwise preserve the existing DB role for known users; a first-time user
|
||||
# is an admin only while the instance has none (so nobody is locked out),
|
||||
# after that PROXY_AUTH_DEFAULT_ROLE decides (default: user).
|
||||
admin_group_header = (
|
||||
normalize_optional_text(
|
||||
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
|
||||
)
|
||||
or "X-Auth-Groups"
|
||||
)
|
||||
admin_group_name = (
|
||||
normalize_optional_text(app_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "")) or ""
|
||||
)
|
||||
is_admin = True
|
||||
|
||||
if admin_group_name:
|
||||
groups_header = get_proxy_header(admin_group_header) or ""
|
||||
user_groups_delimiter = "," if "," in groups_header else "|"
|
||||
user_groups = [
|
||||
g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()
|
||||
]
|
||||
is_admin = admin_group_name in user_groups
|
||||
elif user_db is not None:
|
||||
existing_db_user = user_db.get_user_cached(username=username)
|
||||
if existing_db_user:
|
||||
is_admin = existing_db_user.get("role") == "admin"
|
||||
else:
|
||||
is_admin = _proxy_default_is_admin(user_db)
|
||||
|
||||
# Create or update session
|
||||
previous_username = session.get("user_id")
|
||||
if previous_username and previous_username != username:
|
||||
# Header identity changed mid-session; force reprovision for the new user.
|
||||
session.pop("db_user_id", None)
|
||||
|
||||
session["user_id"] = username
|
||||
session["is_admin"] = is_admin
|
||||
|
||||
# Provision proxy-authenticated users into users.db for multi-user features.
|
||||
# Re-provision when db_user_id is missing/stale/mismatched to avoid broken
|
||||
# sessions after DB resets or auth-mode transitions.
|
||||
if user_db is not None:
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
session_db_user = None
|
||||
|
||||
if raw_db_user_id is not None:
|
||||
try:
|
||||
session_db_user = user_db.get_user_cached(user_id=int(raw_db_user_id))
|
||||
except TypeError, ValueError:
|
||||
session_db_user = None
|
||||
|
||||
session_db_username = (
|
||||
str(session_db_user.get("username") or "").strip() if session_db_user else ""
|
||||
)
|
||||
needs_db_user_sync = (
|
||||
raw_db_user_id is None
|
||||
or session_db_user is None
|
||||
or session_db_username != username
|
||||
)
|
||||
|
||||
if needs_db_user_sync:
|
||||
role = "admin" if is_admin else "user"
|
||||
db_user, _ = upsert_external_user(
|
||||
user_db,
|
||||
auth_source="proxy",
|
||||
username=username,
|
||||
role=role,
|
||||
collision_strategy="takeover",
|
||||
context="proxy_request",
|
||||
)
|
||||
if db_user is None:
|
||||
_raise_runtime_error("Unexpected proxy user sync result: no user returned")
|
||||
|
||||
session["db_user_id"] = db_user["id"]
|
||||
|
||||
session.permanent = False
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("Proxy auth middleware error")
|
||||
return jsonify({"error": "Authentication error"}), 500
|
||||
else:
|
||||
return None
|
||||
|
||||
@app.after_request
|
||||
def strip_cookie_for_api_key_requests(response: Response) -> Response:
|
||||
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
|
||||
if g.get("api_key_auth"):
|
||||
# Setting `permanent` mutates the session dict (re-marking it
|
||||
# modified), so it must be reset before `modified`, not after.
|
||||
session.permanent = False
|
||||
session.modified = False
|
||||
return response
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Sign-in routes: ``POST /api/auth/login``, ``POST /api/auth/logout``, ``GET /api/auth/check``.
|
||||
|
||||
The failed-login counters and their lockout helpers live here too, as module state
|
||||
shared by everything that imports them (``main`` re-exports the names).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.cwa_user_sync import upsert_cwa_user
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Rate limiting for login attempts
|
||||
# Map usernames to their failed-attempt counters and lockout timestamps.
|
||||
failed_login_attempts: dict[str, dict[str, Any]] = {}
|
||||
MAX_LOGIN_ATTEMPTS = 10
|
||||
LOCKOUT_DURATION_MINUTES = 30
|
||||
LOGIN_ATTEMPT_WARNING_THRESHOLD = 5
|
||||
|
||||
|
||||
def cleanup_old_lockouts() -> None:
|
||||
"""Remove expired lockout entries to prevent memory buildup."""
|
||||
current_time = datetime.now(UTC)
|
||||
expired_users = []
|
||||
for username in list(failed_login_attempts):
|
||||
lockout_until = _get_lockout_until(username, repair_if_locked=True)
|
||||
if lockout_until is not None and lockout_until < current_time:
|
||||
expired_users.append(username)
|
||||
for username in expired_users:
|
||||
logger.info("Lockout expired for user: %s", username)
|
||||
# A login can clear the entry while this runs, so it may be gone already.
|
||||
failed_login_attempts.pop(username, None)
|
||||
|
||||
|
||||
def _get_lockout_until(username: str, *, repair_if_locked: bool = False) -> datetime | None:
|
||||
"""Return a valid lockout timestamp for the user when one exists.
|
||||
|
||||
When a user has already crossed the lockout threshold but the timestamp is
|
||||
missing or malformed, optionally repair the state to keep the lockout in
|
||||
force rather than silently letting the user through.
|
||||
"""
|
||||
lockout_state = failed_login_attempts.get(username)
|
||||
if lockout_state is None:
|
||||
return None
|
||||
|
||||
lockout_until = lockout_state.get("lockout_until")
|
||||
if isinstance(lockout_until, datetime):
|
||||
return lockout_until
|
||||
|
||||
attempt_count = lockout_state.get("count")
|
||||
if repair_if_locked and isinstance(attempt_count, int) and attempt_count >= MAX_LOGIN_ATTEMPTS:
|
||||
repaired_lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
lockout_state["lockout_until"] = repaired_lockout_until
|
||||
logger.warning("Repaired missing lockout timestamp for locked account '%s'", username)
|
||||
return repaired_lockout_until
|
||||
|
||||
if lockout_until is not None:
|
||||
logger.warning("Ignoring invalid lockout timestamp for user '%s'", username)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_account_locked(username: str) -> bool:
|
||||
"""Check if an account is currently locked due to failed login attempts."""
|
||||
cleanup_old_lockouts()
|
||||
|
||||
if username not in failed_login_attempts:
|
||||
return False
|
||||
|
||||
lockout_until = _get_lockout_until(username, repair_if_locked=True)
|
||||
return lockout_until is not None and datetime.now(UTC) < lockout_until
|
||||
|
||||
|
||||
def record_failed_login(username: str, ip_address: str) -> bool:
|
||||
"""Record a failed login attempt and lock account if threshold is reached.
|
||||
|
||||
Returns True if account is now locked, False otherwise.
|
||||
"""
|
||||
if username not in failed_login_attempts:
|
||||
failed_login_attempts[username] = {"count": 0}
|
||||
|
||||
failed_login_attempts[username]["count"] += 1
|
||||
count = failed_login_attempts[username]["count"]
|
||||
|
||||
logger.warning(
|
||||
"Failed login attempt %s/%s for user '%s' from IP %s",
|
||||
count,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
username,
|
||||
ip_address,
|
||||
)
|
||||
|
||||
if count >= MAX_LOGIN_ATTEMPTS:
|
||||
lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
failed_login_attempts[username]["lockout_until"] = lockout_until
|
||||
logger.warning(
|
||||
"Account locked for user '%s' until %s due to %s failed login attempts",
|
||||
username,
|
||||
lockout_until.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
count,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clear_failed_logins(username: str) -> None:
|
||||
"""Clear failed login attempts for a user after successful login."""
|
||||
if failed_login_attempts.pop(username, None) is not None:
|
||||
logger.debug("Cleared failed login attempts for user: %s", username)
|
||||
|
||||
|
||||
def _failed_login_response(username: str, ip_address: str) -> tuple[Response, int]:
|
||||
"""Handle a failed login attempt by recording it and returning the appropriate response."""
|
||||
is_now_locked = record_failed_login(username, ip_address)
|
||||
|
||||
if is_now_locked:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
|
||||
}
|
||||
), 429
|
||||
|
||||
attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]["count"]
|
||||
if attempts_remaining <= LOGIN_ATTEMPT_WARNING_THRESHOLD:
|
||||
return jsonify(
|
||||
{"error": f"Invalid username or password. {attempts_remaining} attempts remaining."}
|
||||
), 401
|
||||
|
||||
return jsonify({"error": "Invalid username or password."}), 401
|
||||
|
||||
|
||||
def register_auth_routes(
|
||||
app: Flask,
|
||||
*,
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
get_client_ip: Callable[[], str],
|
||||
account_is_locked: Callable[[str], bool],
|
||||
disable_local_auth: Callable[[], bool],
|
||||
hide_local_auth: Callable[[], bool],
|
||||
oidc_auto_redirect: Callable[[], bool],
|
||||
get_cwa_db_path: Callable[[], Path | None],
|
||||
check_password: Callable[[str, str], bool],
|
||||
get_auth_check_admin_status: Callable[..., bool],
|
||||
operational_errors: tuple[type[BaseException], ...],
|
||||
) -> None:
|
||||
"""Register the three auth routes.
|
||||
|
||||
Everything that lives in ``main.py`` or is patched there by tests is passed as a callable
|
||||
that is called per request, so a runtime patch of any of them still takes effect.
|
||||
``account_is_locked`` is one of them: ``main.is_account_locked`` is looked up on each login.
|
||||
"""
|
||||
|
||||
@app.route("/api/auth/login", methods=["POST"])
|
||||
def api_login() -> Response | tuple[Response, int]:
|
||||
"""Login endpoint that validates credentials and creates a session.
|
||||
|
||||
Supports both built-in credentials and CWA database authentication.
|
||||
Includes rate limiting: 10 failed attempts = 30 minute lockout.
|
||||
|
||||
Request Body:
|
||||
username (str): Username
|
||||
password (str): Password
|
||||
remember_me (bool): Whether to extend session duration
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with success status or error message.
|
||||
|
||||
"""
|
||||
try:
|
||||
ip_address = get_client_ip()
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "proxy":
|
||||
return jsonify({"error": "Proxy authentication is enabled"}), 401
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and disable_local_auth():
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
if auth_mode == "oidc" and hide_local_auth():
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
remember_me = data.get("remember_me", False)
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Username and password are required"}), 400
|
||||
|
||||
# Check if account is locked due to failed login attempts
|
||||
if account_is_locked(username):
|
||||
lockout_until = _get_lockout_until(username, repair_if_locked=True)
|
||||
if lockout_until is None:
|
||||
logger.error("Locked account '%s' is missing a lockout timestamp", username)
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
|
||||
}
|
||||
), 429
|
||||
remaining_time = (lockout_until - datetime.now(UTC)).total_seconds() / 60
|
||||
logger.warning(
|
||||
"Login attempt blocked for locked account '%s' from IP %s", username, ip_address
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes."
|
||||
}
|
||||
), 429
|
||||
|
||||
# If no authentication is configured, authentication always succeeds
|
||||
if auth_mode == "none":
|
||||
session["user_id"] = username
|
||||
session.permanent = remember_me
|
||||
clear_failed_logins(username)
|
||||
logger.info(
|
||||
"Login successful for user '%s' from IP %s (no auth configured)",
|
||||
username,
|
||||
ip_address,
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
# Password authentication (builtin and OIDC modes)
|
||||
# OIDC mode also allows password login as a fallback so admins don't get locked out
|
||||
if auth_mode in ("builtin", "oidc"):
|
||||
user_db = get_user_db()
|
||||
if user_db is None:
|
||||
logger.error("User database not available for %s auth", auth_mode)
|
||||
return jsonify({"error": "Authentication service unavailable"}), 503
|
||||
try:
|
||||
db_user = user_db.get_user(username=username)
|
||||
|
||||
if not db_user:
|
||||
return _failed_login_response(username, ip_address)
|
||||
|
||||
# Authenticate against DB user
|
||||
if not db_user.get("password_hash") or not check_password(
|
||||
db_user["password_hash"], password
|
||||
):
|
||||
return _failed_login_response(username, ip_address)
|
||||
|
||||
is_admin = db_user["role"] == "admin"
|
||||
session["user_id"] = username
|
||||
session["db_user_id"] = db_user["id"]
|
||||
session["is_admin"] = is_admin
|
||||
session.permanent = remember_me
|
||||
clear_failed_logins(username)
|
||||
logger.info(
|
||||
"Login successful for user '%s' from IP %s (%s auth, is_admin=%s, remember_me=%s)",
|
||||
username,
|
||||
ip_address,
|
||||
auth_mode,
|
||||
is_admin,
|
||||
remember_me,
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
except operational_errors as e:
|
||||
logger.error_trace(f"Built-in auth error: {e}")
|
||||
return jsonify({"error": "Authentication system error"}), 500
|
||||
|
||||
# CWA database authentication mode
|
||||
if auth_mode == "cwa":
|
||||
# Verify database still exists (it was validated at startup)
|
||||
cwa_db_path = get_cwa_db_path()
|
||||
if not cwa_db_path or not cwa_db_path.exists():
|
||||
logger.error("CWA database at %s is no longer accessible", cwa_db_path)
|
||||
return jsonify({"error": "Database configuration error"}), 500
|
||||
|
||||
try:
|
||||
db_path = os.fspath(cwa_db_path)
|
||||
db_uri = f"file:{db_path}?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(db_uri, uri=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT password, role, email FROM user WHERE name = ?", (username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
# Check if user exists and password is correct
|
||||
if not row or not row[0] or not check_password(row[0], password):
|
||||
return _failed_login_response(username, ip_address)
|
||||
|
||||
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
|
||||
user_role = row[1] if row[1] is not None else 0
|
||||
is_admin = (user_role & 1) == 1
|
||||
cwa_email = row[2] or None
|
||||
|
||||
db_user_id = None
|
||||
user_db = get_user_db()
|
||||
if user_db is not None:
|
||||
role = "admin" if is_admin else "user"
|
||||
db_user, _ = upsert_cwa_user(
|
||||
user_db,
|
||||
cwa_username=username,
|
||||
cwa_email=cwa_email,
|
||||
role=role,
|
||||
context="cwa_login",
|
||||
)
|
||||
db_user_id = db_user["id"]
|
||||
|
||||
# Successful authentication - create session and clear failed attempts
|
||||
session["user_id"] = username
|
||||
session["is_admin"] = is_admin
|
||||
if db_user_id is not None:
|
||||
session["db_user_id"] = db_user_id
|
||||
session.permanent = remember_me
|
||||
clear_failed_logins(username)
|
||||
logger.info(
|
||||
"Login successful for user '%s' from IP %s (CWA auth, is_admin=%s, remember_me=%s)",
|
||||
username,
|
||||
ip_address,
|
||||
is_admin,
|
||||
remember_me,
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
except operational_errors as e:
|
||||
logger.error_trace(f"CWA database error during login: {e}")
|
||||
return jsonify({"error": "Authentication system error"}), 500
|
||||
|
||||
# Should not reach here, but handle gracefully
|
||||
return jsonify({"error": "Unknown authentication mode"}), 500
|
||||
|
||||
except operational_errors as e:
|
||||
logger.error_trace(f"Login error: {e}")
|
||||
return jsonify({"error": "Login failed"}), 500
|
||||
|
||||
@app.route("/api/auth/logout", methods=["POST"])
|
||||
def api_logout() -> Response | tuple[Response, int]:
|
||||
"""Logout endpoint that clears the session.
|
||||
|
||||
For proxy auth, returns the logout URL if configured.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with success status and optional logout_url.
|
||||
|
||||
"""
|
||||
try:
|
||||
auth_mode = resolve_auth_mode()
|
||||
ip_address = get_client_ip()
|
||||
username = session.get("user_id", "unknown")
|
||||
session.clear()
|
||||
logger.info("Logout successful for user '%s' from IP %s", username, ip_address)
|
||||
|
||||
# For proxy auth, include logout URL if configured
|
||||
if auth_mode == "proxy":
|
||||
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
|
||||
if logout_url:
|
||||
return jsonify({"success": True, "logout_url": logout_url})
|
||||
|
||||
return jsonify({"success": True})
|
||||
except operational_errors as e:
|
||||
logger.error_trace(f"Logout error: {e}")
|
||||
return jsonify({"error": "Logout failed"}), 500
|
||||
|
||||
@app.route("/api/auth/check", methods=["GET"])
|
||||
def api_auth_check() -> Response | tuple[Response, int]:
|
||||
"""Check if user has a valid session.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with authentication status, whether auth is required,
|
||||
which auth mode is active, and whether user has admin privileges.
|
||||
|
||||
"""
|
||||
try:
|
||||
auth_mode = resolve_auth_mode()
|
||||
|
||||
# If no authentication is configured, access is allowed (full admin)
|
||||
if auth_mode == "none":
|
||||
return jsonify(
|
||||
{
|
||||
"authenticated": True,
|
||||
"auth_required": False,
|
||||
"auth_mode": "none",
|
||||
"is_admin": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Check if user has a valid session
|
||||
is_authenticated = "user_id" in session
|
||||
|
||||
is_admin = get_auth_check_admin_status(auth_mode, {}, session)
|
||||
|
||||
display_name = None
|
||||
user_db = get_user_db()
|
||||
if is_authenticated and session.get("db_user_id") and user_db is not None:
|
||||
try:
|
||||
db_user = user_db.get_user(user_id=session["db_user_id"])
|
||||
if db_user:
|
||||
display_name = db_user.get("display_name") or None
|
||||
except (sqlite3.Error, TypeError, ValueError) as exc:
|
||||
logger.debug("Could not load display name for session user: %s", exc)
|
||||
|
||||
response_data = {
|
||||
"authenticated": is_authenticated,
|
||||
"auth_required": True,
|
||||
"auth_mode": auth_mode,
|
||||
"is_admin": is_admin if is_authenticated else False,
|
||||
"username": session.get("user_id") if is_authenticated else None,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
# Add logout URL for proxy auth if configured
|
||||
if auth_mode == "proxy" and app_config.get("PROXY_AUTH_USER_HEADER", ""):
|
||||
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
|
||||
if logout_url:
|
||||
response_data["logout_url"] = logout_url
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and disable_local_auth():
|
||||
response_data["hide_local_auth"] = True
|
||||
|
||||
# Add custom OIDC button label and SSO enforcement flags if configured
|
||||
if auth_mode == "oidc":
|
||||
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
|
||||
if oidc_button_label:
|
||||
response_data["oidc_button_label"] = oidc_button_label
|
||||
if hide_local_auth():
|
||||
response_data["hide_local_auth"] = True
|
||||
if oidc_auto_redirect():
|
||||
response_data["oidc_auto_redirect"] = True
|
||||
|
||||
return jsonify(response_data)
|
||||
except operational_errors as e:
|
||||
logger.error_trace(f"Auth check error: {e}")
|
||||
return jsonify(
|
||||
{
|
||||
"authenticated": False,
|
||||
"auth_required": True,
|
||||
"auth_mode": "unknown",
|
||||
"is_admin": False,
|
||||
}
|
||||
)
|
||||
+133
-15
@@ -31,9 +31,19 @@ from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.release_search import search_source_releases
|
||||
from shelfmark.core.request_helpers import coerce_int
|
||||
from shelfmark.core.request_policy import normalize_content_type
|
||||
from shelfmark.core.request_helpers import (
|
||||
coerce_bool,
|
||||
coerce_int,
|
||||
load_users_request_policy_settings,
|
||||
)
|
||||
from shelfmark.core.request_policy import (
|
||||
PolicyMode,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.request_validation import RequestStatus
|
||||
from shelfmark.core.status_messages import client_problem
|
||||
from shelfmark.core.text_match import (
|
||||
DEFAULT_TITLE_MATCH_THRESHOLD,
|
||||
PACKAGING_MARKERS,
|
||||
@@ -56,7 +66,7 @@ from shelfmark.download.postprocess.policy import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
@@ -1021,15 +1031,6 @@ def _latest_history(db_path: str, request_ids: list[int]) -> dict[int, tuple[str
|
||||
return latest
|
||||
|
||||
|
||||
# What a download records when its client could not be reached. The release is not to blame.
|
||||
_CLIENT_UNREACHABLE = (
|
||||
"failed to connect",
|
||||
"cannot connect",
|
||||
"connection refused",
|
||||
"connection aborted",
|
||||
)
|
||||
|
||||
|
||||
def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
|
||||
"""Why a request whose delivery never completed never arrived, from its latest history row.
|
||||
|
||||
@@ -1045,8 +1046,9 @@ def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
|
||||
return _INTERRUPTED
|
||||
if final_status == "error":
|
||||
lowered = (message or "").strip().lower()
|
||||
if lowered == _INTERRUPTED_MESSAGE or any(m in lowered for m in _CLIENT_UNREACHABLE):
|
||||
# Nothing was wrong with the release: the process stopped, or its client was down.
|
||||
if lowered == _INTERRUPTED_MESSAGE or client_problem(lowered):
|
||||
# Nothing was wrong with the release: the process stopped, or its client was down
|
||||
# or refused the login.
|
||||
return _INTERRUPTED
|
||||
return _FAILED
|
||||
if final_status == "cancelled" and (message or "").strip().lower().startswith(
|
||||
@@ -1169,6 +1171,7 @@ def reopen_stale_failures(
|
||||
failure_cooldown: timedelta | None = None,
|
||||
only_kinds: frozenset[str] | None = None,
|
||||
reopened_ids: list[int] | None = None,
|
||||
owner_policy: OwnerPolicy | None = None,
|
||||
) -> int:
|
||||
"""Return synced requests whose download never arrived to pending once the cooldown passes.
|
||||
|
||||
@@ -1188,6 +1191,9 @@ def reopen_stale_failures(
|
||||
skipped next time, and is given up on after ``MAX_FAILURE_RETRIES``. Other cancelled requests
|
||||
and rejected ones are decisions and are never touched. Returns how many were reopened; ``cooldown_days`` of 0 or less turns retries off,
|
||||
and without readable history nothing is retried, since guessing would be worse.
|
||||
|
||||
A request auto-download could not fulfil on its owner's behalf (see ``OwnerPolicy``) is
|
||||
not reopened either: nothing would pick it up again without an admin.
|
||||
"""
|
||||
if cooldown_days <= 0 and failure_cooldown is None:
|
||||
return 0
|
||||
@@ -1222,11 +1228,14 @@ def reopen_stale_failures(
|
||||
return 0
|
||||
|
||||
stale.sort(key=lambda item: item[0])
|
||||
policy = owner_policy or OwnerPolicy(user_db)
|
||||
reopened = 0
|
||||
for _changed, row, state in stale:
|
||||
if reopened >= max_reopen:
|
||||
break
|
||||
request_id = int(row["id"])
|
||||
if not policy.may_fulfil(row):
|
||||
continue
|
||||
kind = _failure_kind(history.get(request_id))
|
||||
if kind is None or (state == _CANCELLED and kind != _STALLED):
|
||||
continue
|
||||
@@ -1315,6 +1324,95 @@ def _configured_source_priority(content_type: str) -> list[str]:
|
||||
return [src["name"] for src in available if src.get("enabled")]
|
||||
|
||||
|
||||
class OwnerPolicy:
|
||||
"""What a request's owner could download without asking, read once per pass.
|
||||
|
||||
Auto-download fulfils a request as an admin, so it may only do what the owner could have
|
||||
done alone: the same check the download route makes before it queues a release. An admin,
|
||||
an install without login, or a user for whom requests are turned off downloads directly
|
||||
from any source. Anyone else may only use the sources whose resolved policy mode is
|
||||
``download``. A request with no such source is left exactly as it is, pending for an
|
||||
admin, like any other request awaiting review.
|
||||
"""
|
||||
|
||||
def __init__(self, user_db: UserDB, *, auth_mode: str | None = None) -> None:
|
||||
"""Read nothing yet.
|
||||
|
||||
The policy settings and each owner are loaded on first use and kept for the pass.
|
||||
"""
|
||||
from shelfmark.core.auth_modes import AUTH_MODE_NONE, load_active_auth_mode
|
||||
|
||||
self._user_db = user_db
|
||||
self._bypass = (auth_mode or load_active_auth_mode()) == AUTH_MODE_NONE
|
||||
self._global: Mapping[str, Any] | None = None
|
||||
self._owners: dict[int, tuple[bool, dict[str, Any]] | None] = {}
|
||||
|
||||
def _owner(self, user_id: int) -> tuple[bool, dict[str, Any]] | None:
|
||||
"""(owner may skip the policy, their settings), or None when they cannot be read."""
|
||||
if user_id not in self._owners:
|
||||
try:
|
||||
user = self._user_db.get_user(user_id=user_id)
|
||||
settings = self._user_db.get_user_settings(user_id) if user else {}
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("auto-download: could not read user %s: %s", user_id, exc)
|
||||
user, settings = None, {}
|
||||
if user is None:
|
||||
self._owners[user_id] = None
|
||||
else:
|
||||
if self._global is None:
|
||||
self._global = load_users_request_policy_settings()
|
||||
effective = merge_request_policy_settings(self._global, settings)
|
||||
exempt = user.get("role") == "admin" or not coerce_bool(
|
||||
effective.get("REQUESTS_ENABLED"), default=False
|
||||
)
|
||||
self._owners[user_id] = (exempt, settings)
|
||||
return self._owners[user_id]
|
||||
|
||||
def download_sources(
|
||||
self, row: dict[str, Any], content_type: str, sources: list[str]
|
||||
) -> list[str]:
|
||||
"""The sources in ``sources`` the owner of ``row`` may download from directly."""
|
||||
if self._bypass:
|
||||
return list(sources)
|
||||
user_id = coerce_int(row.get("user_id"), 0)
|
||||
owner = self._owner(user_id) if user_id > 0 else None
|
||||
if owner is None:
|
||||
return []
|
||||
exempt, settings = owner
|
||||
if exempt:
|
||||
return list(sources)
|
||||
return [
|
||||
source
|
||||
for source in sources
|
||||
if resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=self._global,
|
||||
user_settings=settings,
|
||||
)
|
||||
== PolicyMode.DOWNLOAD
|
||||
]
|
||||
|
||||
def may_fulfil(self, row: dict[str, Any]) -> bool:
|
||||
"""True when auto-download could fulfil ``row`` from at least one configured source."""
|
||||
if self._bypass:
|
||||
return True
|
||||
user_id = coerce_int(row.get("user_id"), 0)
|
||||
owner = self._owner(user_id) if user_id > 0 else None
|
||||
if owner is None:
|
||||
return False
|
||||
if owner[0]:
|
||||
return True
|
||||
book_data = row.get("book_data")
|
||||
content_type = normalize_content_type(
|
||||
row.get("content_type")
|
||||
or (book_data.get("content_type") if isinstance(book_data, dict) else None)
|
||||
)
|
||||
return bool(
|
||||
self.download_sources(row, content_type, _configured_source_priority(content_type))
|
||||
)
|
||||
|
||||
|
||||
def _acted_on_since_listed(user_db: UserDB, request_id: int) -> bool:
|
||||
"""True only when the request is read back and is positively no longer waiting.
|
||||
|
||||
@@ -1636,6 +1734,7 @@ def retry_failed_downloads(
|
||||
if minutes <= 0 or not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
|
||||
return None
|
||||
reopened_ids: list[int] = []
|
||||
owner_policy = OwnerPolicy(user_db)
|
||||
reopen_stale_failures(
|
||||
user_db,
|
||||
cooldown_days=0,
|
||||
@@ -1644,6 +1743,7 @@ def retry_failed_downloads(
|
||||
failure_cooldown=timedelta(minutes=minutes),
|
||||
only_kinds=frozenset({_FAILED, _STALLED}),
|
||||
reopened_ids=reopened_ids,
|
||||
owner_policy=owner_policy,
|
||||
)
|
||||
# A failed download the status poll already reopened is pending with no release; those
|
||||
# are searched here as well, so they are not left for the next full pass.
|
||||
@@ -1692,6 +1792,10 @@ def auto_download_pending(
|
||||
priority list, format guard and library check all follow the request. Returns a summary
|
||||
count dict. No-ops (returns zeros) unless AUTO_DOWNLOAD_ENABLED. ``admin_user_id``
|
||||
defaults to the lowest-id admin, who fulfils the requests.
|
||||
|
||||
A request is only searched from the sources its owner could download from directly
|
||||
(``OwnerPolicy``); one with none is left untouched for an admin to review and counted
|
||||
under ``awaiting_approval``.
|
||||
"""
|
||||
zeros = {"queued": 0, "no_match": 0, "in_library": 0, "skipped": 0, "error": 0}
|
||||
if not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
|
||||
@@ -1705,6 +1809,7 @@ def auto_download_pending(
|
||||
logger.warning("auto-download: no admin user exists to fulfil requests")
|
||||
return zeros
|
||||
|
||||
owner_policy = OwnerPolicy(user_db)
|
||||
retry_days = coerce_int(
|
||||
app_config.get(RETRY_DAYS_SETTING, DEFAULT_RETRY_DAYS), DEFAULT_RETRY_DAYS
|
||||
)
|
||||
@@ -1712,7 +1817,11 @@ def auto_download_pending(
|
||||
0
|
||||
if only_request_ids is not None
|
||||
else reopen_stale_failures(
|
||||
user_db, cooldown_days=retry_days, db_path=db_path, provider_filter=provider_filter
|
||||
user_db,
|
||||
cooldown_days=retry_days,
|
||||
db_path=db_path,
|
||||
provider_filter=provider_filter,
|
||||
owner_policy=owner_policy,
|
||||
)
|
||||
)
|
||||
if reopened:
|
||||
@@ -1764,6 +1873,15 @@ def auto_download_pending(
|
||||
if not sources:
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
# Only what the owner could download without asking; the rest waits for an admin.
|
||||
sources = owner_policy.download_sources(row, content_type, sources)
|
||||
if not sources:
|
||||
logger.debug(
|
||||
"auto-download: request %s needs an admin's approval under its owner's policy",
|
||||
row.get("id"),
|
||||
)
|
||||
summary["awaiting_approval"] = summary.get("awaiting_approval", 0) + 1
|
||||
continue
|
||||
|
||||
if not _search_is_due(book_data, datetime.now(UTC), backoff_days):
|
||||
logger.debug(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Download recording: write each queued download to the history and, when it reaches a
|
||||
terminal status, finalize the row, notify, push the activity update and reopen a failed request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES, QueueStatus
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
NotificationEvent,
|
||||
notify_admin,
|
||||
notify_user,
|
||||
)
|
||||
from shelfmark.core.request_helpers import (
|
||||
emit_ws_event,
|
||||
normalize_optional_text,
|
||||
normalize_positive_int,
|
||||
)
|
||||
from shelfmark.core.request_policy import normalize_content_type, normalize_source
|
||||
from shelfmark.core.requests_service import reopen_failed_request
|
||||
from shelfmark.core.viewer_redaction import viewer_download_id
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.release_sources import get_source_display_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.activity_view_state_service import ActivityViewStateService
|
||||
from shelfmark.core.download_history_service import DownloadHistoryService
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
|
||||
|
||||
def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None:
|
||||
"""The status to record in download history, or None when the download has not finished."""
|
||||
return status.value if status in TERMINAL_QUEUE_STATUSES else None
|
||||
|
||||
|
||||
def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None:
|
||||
"""The notification for a finished download, or None for a status that sends none."""
|
||||
if status == QueueStatus.COMPLETE:
|
||||
return NotificationEvent.DOWNLOAD_COMPLETE
|
||||
if status == QueueStatus.ERROR:
|
||||
return NotificationEvent.DOWNLOAD_FAILED
|
||||
return None
|
||||
|
||||
|
||||
def _notify_admin_for_terminal_download_status(
|
||||
*, task_id: str, status: QueueStatus, task: Any
|
||||
) -> None:
|
||||
"""Notify the admins, and the user who owns the task, that a download completed or failed.
|
||||
|
||||
A notifier that raises is logged and does not stop the other.
|
||||
"""
|
||||
event = _queue_status_to_notification_event(status)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
raw_owner_user_id = getattr(task, "user_id", None)
|
||||
try:
|
||||
owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None
|
||||
except TypeError, ValueError:
|
||||
owner_user_id = None
|
||||
|
||||
content_type = normalize_optional_text(getattr(task, "content_type", None))
|
||||
context = NotificationContext(
|
||||
event=event,
|
||||
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
|
||||
author=str(getattr(task, "author", "Unknown author") or "Unknown author"),
|
||||
username=normalize_optional_text(getattr(task, "username", None)),
|
||||
content_type=normalize_content_type(content_type) if content_type is not None else None,
|
||||
format=normalize_optional_text(getattr(task, "format", None)),
|
||||
source=normalize_source(getattr(task, "source", None)),
|
||||
error_message=(
|
||||
normalize_optional_text(getattr(task, "status_message", None))
|
||||
if event == NotificationEvent.DOWNLOAD_FAILED
|
||||
else None
|
||||
),
|
||||
)
|
||||
try:
|
||||
notify_admin(event, context)
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Failed to trigger admin notification for download %s (%s): %s",
|
||||
task_id,
|
||||
status.value,
|
||||
exc,
|
||||
)
|
||||
if owner_user_id is None:
|
||||
return
|
||||
try:
|
||||
notify_user(owner_user_id, event, context)
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Failed to trigger user notification for download %s (%s, user_id=%s): %s",
|
||||
task_id,
|
||||
status.value,
|
||||
owner_user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def register_download_recording(
|
||||
*,
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
get_download_history_service: Callable[[], DownloadHistoryService | None],
|
||||
get_activity_view_state_service: Callable[[], ActivityViewStateService | None],
|
||||
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
|
||||
ws_manager: object,
|
||||
) -> None:
|
||||
"""Hook the download queue so every queued and finished download is recorded."""
|
||||
|
||||
def _emit_activity_update_for_task(*, payload: dict[str, Any], task: Any) -> None:
|
||||
"""Send an activity update to the admins and to the user who owns the task."""
|
||||
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="activity_update",
|
||||
room="admins",
|
||||
payload=payload,
|
||||
)
|
||||
if owner_user_id is None:
|
||||
return
|
||||
# The owner knows a request-linked download by its opaque id, as /api/status
|
||||
# shows it, since the task id can carry a private tracker's passkey (#1418).
|
||||
task_id = payload.get("task_id")
|
||||
if isinstance(task_id, str):
|
||||
payload = {
|
||||
**payload,
|
||||
"task_id": viewer_download_id(task_id, getattr(task, "request_id", None)),
|
||||
}
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="activity_update",
|
||||
room=f"user_{owner_user_id}",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def _record_download_queued(task_id: str, task: Any) -> None:
|
||||
"""Persist initial download record when a task enters the queue."""
|
||||
history_service = get_download_history_service()
|
||||
if history_service is None:
|
||||
return
|
||||
|
||||
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
|
||||
request_id = normalize_positive_int(getattr(task, "request_id", None))
|
||||
origin = "requested" if request_id else "direct"
|
||||
|
||||
source_name = normalize_source(getattr(task, "source", None))
|
||||
source_display = get_source_display_name(source_name)
|
||||
|
||||
try:
|
||||
history_service.record_download(
|
||||
task_id=task_id,
|
||||
user_id=owner_user_id,
|
||||
username=normalize_optional_text(getattr(task, "username", None)),
|
||||
request_id=request_id,
|
||||
source=source_name,
|
||||
source_display_name=source_display,
|
||||
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
|
||||
author=normalize_optional_text(getattr(task, "author", None)),
|
||||
file_format=normalize_optional_text(getattr(task, "format", None)),
|
||||
size=normalize_optional_text(getattr(task, "size", None)),
|
||||
preview=normalize_optional_text(getattr(task, "preview", None)),
|
||||
content_type=normalize_optional_text(getattr(task, "content_type", None)),
|
||||
downloads=getattr(task, "downloads", None),
|
||||
origin=origin,
|
||||
retry_payload=backend.serialize_task_for_retry(task),
|
||||
)
|
||||
except _OPERATIONAL_ERRORS as exc:
|
||||
logger.warning("Failed to record download at queue time for task %s: %s", task_id, exc)
|
||||
return
|
||||
|
||||
view_state_service = get_activity_view_state_service()
|
||||
if view_state_service is None:
|
||||
return
|
||||
|
||||
try:
|
||||
cleared_view_state = 0
|
||||
cleared_view_state += view_state_service.clear_item_for_all_viewers(
|
||||
item_type="download",
|
||||
item_key=f"download:{task_id}",
|
||||
)
|
||||
if request_id is not None:
|
||||
cleared_view_state += view_state_service.clear_item_for_all_viewers(
|
||||
item_type="request",
|
||||
item_key=f"request:{request_id}",
|
||||
)
|
||||
if cleared_view_state > 0:
|
||||
_emit_activity_update_for_task(
|
||||
task=task,
|
||||
payload={
|
||||
"kind": "activity_reset",
|
||||
"task_id": task_id,
|
||||
},
|
||||
)
|
||||
except _OPERATIONAL_ERRORS as exc:
|
||||
logger.warning("Failed to reset activity viewer state for task %s: %s", task_id, exc)
|
||||
|
||||
def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None:
|
||||
"""Record a finished download: notify, finalize its history and tell the Activity view.
|
||||
|
||||
A failed download that cannot be retried also reopens the request it was fulfilling.
|
||||
"""
|
||||
_notify_admin_for_terminal_download_status(task_id=task_id, status=status, task=task)
|
||||
|
||||
final_status = _queue_status_to_final_activity_status(status)
|
||||
if final_status is None:
|
||||
return
|
||||
|
||||
history_service = get_download_history_service()
|
||||
view_state_service = get_activity_view_state_service()
|
||||
user_db = get_user_db()
|
||||
finalized_download = False
|
||||
if history_service is not None:
|
||||
try:
|
||||
history_service.finalize_download(
|
||||
task_id=task_id,
|
||||
final_status=final_status,
|
||||
status_message=normalize_optional_text(getattr(task, "status_message", None)),
|
||||
download_path=normalize_optional_text(getattr(task, "download_path", None)),
|
||||
retry_payload=backend.serialize_task_for_retry(task),
|
||||
)
|
||||
finalized_download = True
|
||||
except _OPERATIONAL_ERRORS as exc:
|
||||
logger.warning("Failed to finalize download history for task %s: %s", task_id, exc)
|
||||
|
||||
if finalized_download:
|
||||
_emit_activity_update_for_task(
|
||||
task=task,
|
||||
payload={
|
||||
"kind": "download_terminal",
|
||||
"task_id": task_id,
|
||||
"status": final_status,
|
||||
},
|
||||
)
|
||||
|
||||
if user_db is None or status != QueueStatus.ERROR:
|
||||
return
|
||||
|
||||
request_id = normalize_positive_int(getattr(task, "request_id", None))
|
||||
if request_id is None:
|
||||
return
|
||||
if backend.can_retry_download_task(task, status):
|
||||
return
|
||||
|
||||
raw_error_message = getattr(task, "status_message", None)
|
||||
fallback_reason = (
|
||||
raw_error_message.strip()
|
||||
if isinstance(raw_error_message, str) and raw_error_message.strip()
|
||||
else "Download failed"
|
||||
)
|
||||
try:
|
||||
reopened_request = reopen_failed_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
failure_reason=fallback_reason,
|
||||
)
|
||||
if reopened_request is not None:
|
||||
if view_state_service is not None:
|
||||
view_state_service.clear_item_for_all_viewers(
|
||||
item_type="request",
|
||||
item_key=f"request:{request_id}",
|
||||
)
|
||||
emit_request_update_events([reopened_request])
|
||||
except _OPERATIONAL_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Failed to reopen request %s after terminal download error %s: %s",
|
||||
request_id,
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
backend.book_queue.set_queue_hook(_record_download_queued)
|
||||
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Metadata providers: the ``/api/metadata/...`` search, book and target routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import get_session_db_user_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.metadata_providers import BookMetadata, MetadataProvider
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
|
||||
|
||||
|
||||
def _resolve_metadata_provider(provider_name: str) -> MetadataProvider:
|
||||
"""Validate, instantiate and return a ready metadata provider.
|
||||
|
||||
Raises appropriate HTTP-friendly exceptions on failure.
|
||||
"""
|
||||
from shelfmark.metadata_providers import (
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
if not is_provider_registered(provider_name):
|
||||
msg = f"Unknown metadata provider: {provider_name}"
|
||||
raise ValueError(msg)
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
prov = get_provider(provider_name, **kwargs)
|
||||
|
||||
if not prov.is_available():
|
||||
msg = f"Provider '{provider_name}' is not available"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return prov
|
||||
|
||||
|
||||
def _handle_target_errors(
|
||||
fallback_message: str,
|
||||
) -> Callable[
|
||||
[Callable[..., Response | tuple[Response, int]]], Callable[..., Response | tuple[Response, int]]
|
||||
]:
|
||||
"""Wrap a metadata-target route with standard error handling."""
|
||||
|
||||
def decorator(
|
||||
fn: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
"""Wrap one route."""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
|
||||
"""Map errors to responses: bad input 400, provider failure 502, anything else 500."""
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except (NotImplementedError, ValueError) as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
except (OSError, TypeError, sqlite3.Error) as e:
|
||||
logger.error_trace(f"{fallback_message}: {e}")
|
||||
return jsonify({"error": fallback_message}), 500
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_metadata_routes(
|
||||
app: Flask,
|
||||
*,
|
||||
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
attach_library_ownership: Callable[[BookMetadata, dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Register the ``/api/metadata/...`` routes.
|
||||
|
||||
``login_required`` is the app's decorator. ``resolve_auth_mode`` is called per request so a
|
||||
runtime patch of the auth mode still takes effect. ``attach_library_ownership`` is shared
|
||||
with the releases routes, which is why it stays in ``main.py``.
|
||||
"""
|
||||
|
||||
def _session_may_use_provider_account() -> bool:
|
||||
"""Whether this session may read or change the metadata provider's own account.
|
||||
|
||||
A provider such as Hardcover is signed in as one account, the instance owner's, and its lists
|
||||
and shelves belong to that person. With no authentication configured the one user is the owner.
|
||||
Otherwise only an admin may use them.
|
||||
"""
|
||||
return resolve_auth_mode() == "none" or bool(session.get("is_admin", False))
|
||||
|
||||
@app.route("/api/metadata/providers", methods=["GET"])
|
||||
@login_required
|
||||
def api_metadata_providers() -> Response | tuple[Response, int]:
|
||||
"""Get list of available metadata providers.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with list of providers and their status.
|
||||
|
||||
"""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_configured_provider_name,
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
list_providers,
|
||||
)
|
||||
|
||||
app_config.refresh()
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
configured_metadata_provider = get_configured_provider_name(
|
||||
content_type="ebook",
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=True,
|
||||
)
|
||||
configured_audiobook_metadata_provider = get_configured_provider_name(
|
||||
content_type="audiobook",
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=False,
|
||||
)
|
||||
configured_combined_metadata_provider = get_configured_provider_name(
|
||||
content_type="combined",
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=False,
|
||||
)
|
||||
providers = []
|
||||
for info in list_providers():
|
||||
enabled_key = f"{info['name'].upper()}_ENABLED"
|
||||
provider_info = {
|
||||
"name": info["name"],
|
||||
"display_name": info["display_name"],
|
||||
"requires_auth": info["requires_auth"],
|
||||
"enabled": app_config.get(enabled_key, False) is True,
|
||||
"available": False,
|
||||
}
|
||||
|
||||
try:
|
||||
kwargs = get_provider_kwargs(info["name"])
|
||||
provider = get_provider(info["name"], **kwargs)
|
||||
provider_info["available"] = provider.is_available()
|
||||
except _OPERATIONAL_ERRORS as exc:
|
||||
logger.debug(
|
||||
"Metadata provider %s availability check failed: %s",
|
||||
info["name"],
|
||||
exc,
|
||||
)
|
||||
|
||||
providers.append(provider_info)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"providers": providers,
|
||||
"configured_provider": configured_metadata_provider or None,
|
||||
"configured_provider_audiobook": configured_audiobook_metadata_provider or None,
|
||||
"configured_provider_combined": configured_combined_metadata_provider or None,
|
||||
}
|
||||
)
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Metadata providers error: {e}")
|
||||
return jsonify({"error": "Could not load the metadata providers"}), 500
|
||||
|
||||
@app.route("/api/metadata/config", methods=["GET"])
|
||||
@login_required
|
||||
def api_metadata_config() -> Response | tuple[Response, int]:
|
||||
"""Return provider-specific metadata search config for the active session."""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_configured_provider_name,
|
||||
get_provider,
|
||||
get_provider_capabilities,
|
||||
get_provider_default_sort,
|
||||
get_provider_kwargs,
|
||||
get_provider_search_fields,
|
||||
get_provider_sort_options,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
app_config.refresh()
|
||||
content_type = request.args.get("content_type", "ebook").strip()
|
||||
provider_name = request.args.get("provider", "").strip()
|
||||
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
if not provider_name:
|
||||
provider_name = get_configured_provider_name(
|
||||
content_type=content_type,
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=True,
|
||||
)
|
||||
|
||||
if not provider_name:
|
||||
return jsonify(
|
||||
{
|
||||
"provider": None,
|
||||
"display_name": None,
|
||||
"enabled": False,
|
||||
"available": False,
|
||||
"search_fields": [],
|
||||
"capabilities": [],
|
||||
"sort_options": [{"value": "relevance", "label": "Most relevant"}],
|
||||
"default_sort": "relevance",
|
||||
}
|
||||
)
|
||||
|
||||
if not is_provider_registered(provider_name):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
provider = get_provider(provider_name, **kwargs)
|
||||
enabled_key = f"{provider_name.upper()}_ENABLED"
|
||||
provider_enabled = app_config.get(enabled_key, False) is True
|
||||
provider_available = provider.is_available()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"provider": provider_name,
|
||||
"display_name": provider.display_name,
|
||||
"enabled": provider_enabled,
|
||||
"available": provider_available,
|
||||
"search_fields": get_provider_search_fields(provider_name),
|
||||
"capabilities": get_provider_capabilities(provider_name),
|
||||
"sort_options": get_provider_sort_options(provider_name),
|
||||
"default_sort": get_provider_default_sort(provider_name, user_id=db_user_id),
|
||||
}
|
||||
)
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Metadata config error: {e}")
|
||||
return jsonify({"error": "Could not load the metadata configuration"}), 500
|
||||
|
||||
@app.route("/api/metadata/search", methods=["GET"])
|
||||
@login_required
|
||||
def api_metadata_search() -> Response | tuple[Response, int]:
|
||||
"""Search for books using the configured metadata provider.
|
||||
|
||||
Query Parameters:
|
||||
query (str): Search query (required)
|
||||
limit (int): Maximum number of results (default: 40, max: 100)
|
||||
sort (str): Sort order - relevance, popularity, rating, newest, oldest (default: relevance)
|
||||
[dynamic fields]: Provider-specific search fields passed as query params
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with list of books from metadata provider.
|
||||
|
||||
"""
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
|
||||
from shelfmark.metadata_providers import (
|
||||
CheckboxSearchField,
|
||||
MetadataSearchOptions,
|
||||
NumberSearchField,
|
||||
SortOrder,
|
||||
get_configured_provider,
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_enabled,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
query = request.args.get("query", "").strip()
|
||||
content_type = request.args.get("content_type", "ebook").strip()
|
||||
provider_name = request.args.get("provider", "").strip()
|
||||
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", 40)), 100)
|
||||
except ValueError:
|
||||
limit = 40
|
||||
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
# Parse sort parameter
|
||||
sort_value = request.args.get("sort", "relevance").lower()
|
||||
try:
|
||||
sort_order = SortOrder(sort_value)
|
||||
except ValueError:
|
||||
sort_order = SortOrder.RELEVANCE
|
||||
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
if provider_name:
|
||||
if not is_provider_registered(provider_name):
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Unknown metadata provider: {provider_name}",
|
||||
"message": f"Unknown metadata provider: {provider_name}",
|
||||
}
|
||||
), 400
|
||||
if not is_provider_enabled(provider_name):
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Metadata provider '{provider_name}' is not enabled",
|
||||
"message": f"{provider_name} is not enabled. Enable it in Settings first.",
|
||||
}
|
||||
), 503
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
provider = get_provider(provider_name, **kwargs)
|
||||
else:
|
||||
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
|
||||
|
||||
if not provider:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "No metadata provider configured",
|
||||
"message": "No metadata provider configured. Enable one in Settings.",
|
||||
}
|
||||
), 503
|
||||
|
||||
if not provider.is_available():
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Metadata provider '{provider.name}' is not available",
|
||||
"message": f"{provider.display_name} is not available. Check configuration in Settings.",
|
||||
}
|
||||
), 503
|
||||
|
||||
# Extract custom search field values from query params
|
||||
fields: dict[str, Any] = {}
|
||||
may_use_account = _session_may_use_provider_account()
|
||||
for search_field in provider.search_fields:
|
||||
if search_field.key in provider.account_search_fields and not may_use_account:
|
||||
continue
|
||||
value = request.args.get(search_field.key)
|
||||
if value is not None:
|
||||
# Strip string values to handle whitespace-only input
|
||||
value = value.strip()
|
||||
if value != "":
|
||||
# Parse value based on field type
|
||||
if isinstance(search_field, CheckboxSearchField):
|
||||
fields[search_field.key] = value.lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(search_field, NumberSearchField):
|
||||
with suppress(ValueError):
|
||||
fields[search_field.key] = int(value)
|
||||
else:
|
||||
fields[search_field.key] = value
|
||||
|
||||
# Require either a query or at least one field value
|
||||
if not query and not fields:
|
||||
return jsonify({"error": "Either 'query' or search field values are required"}), 400
|
||||
|
||||
options = MetadataSearchOptions(
|
||||
query=query,
|
||||
limit=limit,
|
||||
page=page,
|
||||
sort=sort_order,
|
||||
fields=fields,
|
||||
user_id=db_user_id,
|
||||
)
|
||||
search_result = provider.search_paginated(options)
|
||||
|
||||
# Convert BookMetadata objects to dicts
|
||||
books_data = [asdict(book) for book in search_result.books]
|
||||
|
||||
# Transform cover_url to local proxy URLs when caching is enabled
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
|
||||
for book_dict in books_data:
|
||||
if book_dict.get("cover_url"):
|
||||
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
|
||||
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
|
||||
for book, book_dict in zip(search_result.books, books_data, strict=True):
|
||||
attach_library_ownership(book, book_dict)
|
||||
|
||||
response_data = {
|
||||
"books": books_data,
|
||||
"provider": provider.name,
|
||||
"query": query,
|
||||
"page": search_result.page,
|
||||
"total_found": search_result.total_found,
|
||||
"has_more": search_result.has_more,
|
||||
}
|
||||
if search_result.source_url:
|
||||
response_data["source_url"] = search_result.source_url
|
||||
if search_result.source_title:
|
||||
response_data["source_title"] = search_result.source_title
|
||||
return jsonify(response_data)
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Metadata search error: {e}")
|
||||
return jsonify({"error": "Metadata search failed"}), 500
|
||||
|
||||
@app.route("/api/metadata/field-options", methods=["GET"])
|
||||
@login_required
|
||||
def api_metadata_field_options() -> Response:
|
||||
"""Return dynamic search-field options for a metadata provider."""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_configured_provider,
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
field_key = request.args.get("field", "").strip()
|
||||
provider_name = request.args.get("provider", "").strip()
|
||||
content_type = request.args.get("content_type", "ebook").strip()
|
||||
query_text = request.args.get("query", "").strip()
|
||||
|
||||
if not field_key:
|
||||
return jsonify({"options": []})
|
||||
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
provider = None
|
||||
if provider_name:
|
||||
if not is_provider_registered(provider_name):
|
||||
return jsonify({"options": []})
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
provider = get_provider(provider_name, **kwargs)
|
||||
else:
|
||||
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
|
||||
|
||||
if not provider or not provider.is_available():
|
||||
return jsonify({"options": []})
|
||||
|
||||
if (
|
||||
field_key in provider.account_search_fields
|
||||
and not _session_may_use_provider_account()
|
||||
):
|
||||
return jsonify({"options": []})
|
||||
|
||||
options = provider.get_search_field_options(field_key, query=query_text or None)
|
||||
return jsonify({"options": options})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.warning("Metadata field options endpoint error: %s", e)
|
||||
return jsonify({"options": []})
|
||||
|
||||
@app.route("/api/metadata/book/<provider>/<book_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_metadata_book(provider: str, book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Get detailed book information from a metadata provider.
|
||||
|
||||
Path Parameters:
|
||||
provider (str): Provider name (e.g., "hardcover", "openlibrary")
|
||||
book_id (str): Book ID in the provider's system
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with book details.
|
||||
|
||||
"""
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
book = prov.get_book(book_id)
|
||||
if not book:
|
||||
return jsonify({"error": "Book not found"}), 404
|
||||
|
||||
book_dict = asdict(book)
|
||||
attach_library_ownership(book, book_dict)
|
||||
|
||||
# Transform cover_url to local proxy URL when caching is enabled
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
|
||||
if book_dict.get("cover_url"):
|
||||
cache_id = f"{provider}_{book_id}"
|
||||
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
|
||||
|
||||
return jsonify(book_dict)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 503
|
||||
except (OSError, TypeError, sqlite3.Error) as e:
|
||||
logger.error_trace(f"Metadata book error: {e}")
|
||||
return jsonify({"error": "Could not load the book"}), 500
|
||||
|
||||
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["GET"])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to load book targets")
|
||||
def api_metadata_book_targets(provider: str, book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Get provider-managed list/status targets for a specific book."""
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
if not _session_may_use_provider_account():
|
||||
return jsonify({"options": []})
|
||||
return jsonify({"options": prov.get_book_targets(book_id)})
|
||||
|
||||
@app.route("/api/metadata/book/<provider>/targets/batch", methods=["POST"])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to load book targets")
|
||||
def api_metadata_book_targets_batch(provider: str) -> Response | tuple[Response, int]:
|
||||
"""Get provider-managed list/status targets for multiple books."""
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
raw_ids = payload.get("book_ids", []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
return jsonify({"error": "book_ids must be a non-empty array"}), 400
|
||||
|
||||
book_ids = [str(bid) for bid in raw_ids[:50]]
|
||||
|
||||
if not _session_may_use_provider_account():
|
||||
return jsonify({"results": {book_id: [] for book_id in book_ids}})
|
||||
return jsonify({"results": prov.get_book_targets_batch(book_ids)})
|
||||
|
||||
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["PUT"])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to update book targets")
|
||||
def api_metadata_book_targets_update(
|
||||
provider: str, book_id: str
|
||||
) -> Response | tuple[Response, int]:
|
||||
"""Set whether a book belongs to a provider-managed list or shelf."""
|
||||
if not _session_may_use_provider_account():
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
target = str(payload.get("target", "")).strip() if isinstance(payload, dict) else ""
|
||||
selected = payload.get("selected") if isinstance(payload, dict) else None
|
||||
if not target:
|
||||
return jsonify({"error": "target is required"}), 400
|
||||
if not isinstance(selected, bool):
|
||||
return jsonify({"error": "selected must be a boolean"}), 400
|
||||
|
||||
result = prov.set_book_target_state(book_id, target, selected=selected)
|
||||
response: dict = {
|
||||
"success": True,
|
||||
"changed": bool(result.get("changed", True)),
|
||||
"selected": selected,
|
||||
}
|
||||
deselected = result.get("deselected_target")
|
||||
if isinstance(deselected, str) and deselected:
|
||||
response["deselected_target"] = deselected
|
||||
return jsonify(response)
|
||||
@@ -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.
|
||||
|
||||
@@ -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,15 +179,15 @@ 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:
|
||||
hook = self._terminal_status_hook
|
||||
hook_task = current_task
|
||||
# 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
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in TERMINAL_QUEUE_STATUSES:
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""Download queue routes: status, cancel, retry, priority, order, local file and cover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, jsonify, request, send_file, session
|
||||
|
||||
from shelfmark.core.download_history_service import DownloadHistoryService
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import build_filename
|
||||
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
|
||||
from shelfmark.core.requests_service import sync_delivery_states_from_queue_status
|
||||
from shelfmark.core.viewer_redaction import (
|
||||
is_public_download_id,
|
||||
match_public_download_id,
|
||||
redact_status,
|
||||
viewer_download_id,
|
||||
)
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.download.zip_stream import stream_zip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
|
||||
|
||||
|
||||
def _task_owned_by_actor(
|
||||
task: Any, *, actor_user_id: int | None, actor_username: str | None
|
||||
) -> bool:
|
||||
"""Whether a queue task belongs to the actor: by user id when both have one, else by username."""
|
||||
raw_task_user_id = getattr(task, "user_id", None)
|
||||
try:
|
||||
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
|
||||
except TypeError, ValueError:
|
||||
task_user_id = None
|
||||
|
||||
if actor_user_id is not None and task_user_id is not None:
|
||||
return task_user_id == actor_user_id
|
||||
|
||||
task_username = getattr(task, "username", None)
|
||||
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
|
||||
return task_username.strip() == actor_username.strip()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _download_row_owned_by_actor(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
"""Whether a download history row belongs to the actor, matched the same way as a task."""
|
||||
owner_user_id = normalize_positive_int(row.get("user_id"))
|
||||
if actor_user_id is not None and owner_user_id is not None:
|
||||
return owner_user_id == actor_user_id
|
||||
|
||||
row_username = normalize_optional_text(row.get("username"))
|
||||
if row_username is not None and isinstance(actor_username, str):
|
||||
return row_username == actor_username.strip()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _queue_task_visible_to_actor(
|
||||
task_id: str,
|
||||
*,
|
||||
is_admin: bool,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
"""Whether the actor may see a live queue task: admins see all, others only their own."""
|
||||
if is_admin:
|
||||
return True
|
||||
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
return _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
|
||||
|
||||
def _viewer_queue_task_id(task_id: str) -> str:
|
||||
"""Return the id a non-admin sees for a live queue task."""
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
return viewer_download_id(task_id, getattr(task, "request_id", None))
|
||||
|
||||
|
||||
def _attachment_filename_options(download_name: str) -> dict[str, str]:
|
||||
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
|
||||
|
||||
Mirrors what send_file does, for responses built from a generator.
|
||||
"""
|
||||
try:
|
||||
download_name.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
simple = unicodedata.normalize("NFKD", download_name)
|
||||
simple = simple.encode("ascii", "ignore").decode("ascii")
|
||||
quoted = quote(download_name, safe="!#$&+^`|~")
|
||||
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
|
||||
return {"filename": download_name}
|
||||
|
||||
|
||||
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
|
||||
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
|
||||
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
|
||||
response = Response(stream_zip(file_paths), mimetype="application/zip")
|
||||
response.headers.set(
|
||||
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def register_queue_routes(
|
||||
app: Flask,
|
||||
login_required: Callable[..., Any],
|
||||
*,
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
get_download_history_service: Callable[[], DownloadHistoryService | None],
|
||||
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
|
||||
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
|
||||
) -> None:
|
||||
"""Register the queue routes.
|
||||
|
||||
The history service and the user database are called per request, so a runtime patch of
|
||||
either still takes effect. ``resolve_status_scope`` and ``emit_request_update_events`` stay
|
||||
in ``main.py`` because the release routes and the request routes use them too.
|
||||
"""
|
||||
|
||||
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
|
||||
"""Return ``(is_admin, db_user_id, username, error)`` for the session.
|
||||
|
||||
``error`` is a response to send back when a non-admin has no usable identity.
|
||||
"""
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
|
||||
if not is_admin and (not can_access_status or db_user_id is None):
|
||||
return (
|
||||
is_admin,
|
||||
db_user_id,
|
||||
normalized_actor_username,
|
||||
jsonify(
|
||||
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
|
||||
),
|
||||
)
|
||||
|
||||
return is_admin, db_user_id, normalized_actor_username, None
|
||||
|
||||
def _resolve_viewer_download_id(book_id: str) -> str:
|
||||
"""Map a non-admin's opaque download id back to the task it names (#1418).
|
||||
|
||||
Only the caller's own request-linked downloads are searched, so an id that
|
||||
doesn't resolve comes back unchanged and fails the route's usual lookup.
|
||||
"""
|
||||
download_history_service = get_download_history_service()
|
||||
if not is_public_download_id(book_id):
|
||||
return book_id
|
||||
is_admin, db_user_id, _ = resolve_status_scope()
|
||||
if is_admin or db_user_id is None:
|
||||
return book_id
|
||||
candidates = [
|
||||
task_id
|
||||
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
|
||||
for task_id, task in tasks.items()
|
||||
if getattr(task, "request_id", None) is not None
|
||||
]
|
||||
if download_history_service is not None:
|
||||
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
|
||||
return match_public_download_id(book_id, candidates) or book_id
|
||||
|
||||
@app.route("/api/status", methods=["GET"])
|
||||
@login_required
|
||||
def api_status() -> Response | tuple[Response, int]:
|
||||
"""Get current download queue status.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with queue status.
|
||||
|
||||
"""
|
||||
user_db = get_user_db()
|
||||
try:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not can_access_status:
|
||||
return jsonify({})
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
if user_db is not None:
|
||||
updated_requests = sync_delivery_states_from_queue_status(
|
||||
user_db,
|
||||
queue_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
emit_request_update_events(updated_requests)
|
||||
return jsonify(status if is_admin else redact_status(status))
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Status error: {e}")
|
||||
return jsonify({"error": "Could not read the download status"}), 500
|
||||
|
||||
@app.route("/api/localdownload", methods=["GET"])
|
||||
@login_required
|
||||
def api_local_download() -> Response | tuple[Response, int]:
|
||||
"""Download a completed book from local storage if available.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: The file if found, a ZIP of all of them when the download
|
||||
produced several (a live task only; history keeps just the first), otherwise
|
||||
an error response.
|
||||
|
||||
"""
|
||||
download_history_service = get_download_history_service()
|
||||
book_id = request.args.get("id", "")
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
|
||||
try:
|
||||
# The path, not the bytes: send_file streams it off disk, where reading it
|
||||
# first cost two resident copies of the whole book.
|
||||
file_paths, book_info = backend.get_book_paths(book_id)
|
||||
if not file_paths:
|
||||
# Fallback for dismissed/history entries where queue task may no longer exist.
|
||||
if download_history_service is not None:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if can_access_status:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if history_row is not None:
|
||||
owner_user_id = history_row.get("user_id")
|
||||
if is_admin or owner_user_id == db_user_id:
|
||||
download_path = (
|
||||
DownloadHistoryService._resolve_existing_download_path(
|
||||
history_row.get("download_path")
|
||||
)
|
||||
)
|
||||
if download_path:
|
||||
return send_file(
|
||||
download_path,
|
||||
download_name=Path(download_path).name,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not is_admin:
|
||||
actor_username = session.get("user_id")
|
||||
if not can_access_status or not _task_owned_by_actor(
|
||||
book_info,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username if isinstance(actor_username, str) else None,
|
||||
):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if book_info is not None and len(file_paths) > 1:
|
||||
return _zip_download_response(file_paths, book_info)
|
||||
|
||||
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
|
||||
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
|
||||
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
# The details are logged. The message of an OSError names the file's path on the server,
|
||||
# which a non-admin never sees anywhere else.
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": "Could not read the file"}), 500
|
||||
|
||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
"""Serve a cached book cover image.
|
||||
|
||||
This endpoint proxies and caches cover images from external sources.
|
||||
Images are cached to disk for faster subsequent requests.
|
||||
|
||||
Path Parameters:
|
||||
cover_id (str): Cover identifier (book ID or composite key for universal mode).
|
||||
Kept for the link shape only; the cache entry is chosen by `url`.
|
||||
|
||||
Query Parameters:
|
||||
url (str): Base64-encoded original image URL (required)
|
||||
|
||||
Returns:
|
||||
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
||||
|
||||
"""
|
||||
try:
|
||||
import base64
|
||||
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
|
||||
|
||||
# Check if caching is enabled
|
||||
if not is_covers_cache_enabled():
|
||||
return jsonify({"error": "Cover caching is disabled"}), 404
|
||||
|
||||
encoded_url = request.args.get("url")
|
||||
if not encoded_url:
|
||||
return jsonify({"error": "Cover URL not provided"}), 404
|
||||
|
||||
try:
|
||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
except (binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.warning("Failed to decode cover URL: %s", e)
|
||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||
|
||||
# The entry is keyed by the URL, never by the caller-supplied id: the id would
|
||||
# let any user cache an image of their choosing under another book's link.
|
||||
cache = get_image_cache()
|
||||
cache_key = cover_cache_key(original_url)
|
||||
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
image_data, content_type = cached
|
||||
response = app.response_class(
|
||||
response=image_data, status=200, mimetype=content_type
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return response
|
||||
|
||||
# Fetch and cache the image
|
||||
result = cache.fetch_and_cache(cache_key, original_url)
|
||||
if not result:
|
||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||
|
||||
image_data, content_type = result
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cover fetch error: {e}")
|
||||
return jsonify({"error": "Could not fetch the cover image"}), 500
|
||||
else:
|
||||
return response
|
||||
|
||||
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
|
||||
@login_required
|
||||
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Cancel a download.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
try:
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not is_admin:
|
||||
if not can_access_status or db_user_id is None:
|
||||
return jsonify(
|
||||
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
|
||||
), 403
|
||||
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = (
|
||||
actor_username if isinstance(actor_username, str) else None
|
||||
)
|
||||
if not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=normalized_actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
if getattr(task, "request_id", None) is not None:
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
|
||||
), 403
|
||||
|
||||
success = backend.cancel_download(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cancel download error: {e}")
|
||||
return jsonify({"error": "Could not cancel the download"}), 500
|
||||
|
||||
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
|
||||
@login_required
|
||||
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Retry a failed download."""
|
||||
download_history_service = get_download_history_service()
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
try:
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
history_row = None
|
||||
if task is None and download_history_service is not None:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if task is None and history_row is None:
|
||||
return jsonify({"error": "Download not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
if not is_admin:
|
||||
if not can_access_status or db_user_id is None:
|
||||
return jsonify(
|
||||
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
|
||||
), 403
|
||||
|
||||
if task is not None:
|
||||
if not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=normalized_actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
elif history_row is None or not _download_row_owned_by_actor(
|
||||
history_row,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=normalized_actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
if task is not None:
|
||||
task_status = backend.book_queue.get_task_status(book_id)
|
||||
if getattr(
|
||||
task, "request_id", None
|
||||
) is not None and not backend.can_retry_download_task(task, task_status):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_download(book_id)
|
||||
else:
|
||||
if history_row is None:
|
||||
logger.error("Download history row disappeared while retrying task %s", book_id)
|
||||
return jsonify({"error": "Download history not found"}), 404
|
||||
request_id = normalize_positive_int(history_row.get("request_id"))
|
||||
retry_payload = history_row.get("retry_payload")
|
||||
final_status = history_row.get("final_status")
|
||||
if request_id is not None:
|
||||
history_service = download_history_service
|
||||
if history_service is None:
|
||||
logger.error(
|
||||
"Download history service unavailable while retrying task %s", book_id
|
||||
)
|
||||
return jsonify({"error": "Download history unavailable"}), 500
|
||||
if not history_service.is_retry_available(history_row):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_persisted_download(
|
||||
retry_payload,
|
||||
final_status=final_status,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "queued", "book_id": viewer_book_id})
|
||||
|
||||
if error == "Download not found":
|
||||
return jsonify({"error": error}), 404
|
||||
|
||||
return jsonify({"error": error or "Download cannot be retried"}), 409
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Retry download error: {e}")
|
||||
return jsonify({"error": "Could not retry the download"}), 500
|
||||
|
||||
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
|
||||
@login_required
|
||||
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Set priority for a queued book.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier
|
||||
|
||||
Request Body:
|
||||
priority (int): New priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "priority" not in data:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data["priority"])
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
|
||||
if not is_admin and not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
return jsonify(
|
||||
{"status": "updated", "book_id": viewer_book_id, "priority": priority}
|
||||
)
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid priority value"}), 400
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Set priority error: {e}")
|
||||
return jsonify({"error": "Could not set the priority"}), 500
|
||||
|
||||
@app.route("/api/queue/reorder", methods=["POST"])
|
||||
@login_required
|
||||
def api_reorder_queue() -> Response | tuple[Response, int]:
|
||||
"""Bulk reorder queue by setting new priorities.
|
||||
|
||||
Request Body:
|
||||
book_priorities (dict): Mapping of book_id to new priority
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "book_priorities" not in data:
|
||||
return jsonify({"error": "book_priorities not provided"}), 400
|
||||
|
||||
book_priorities = data["book_priorities"]
|
||||
if not isinstance(book_priorities, dict):
|
||||
return jsonify({"error": "book_priorities must be a dictionary"}), 400
|
||||
|
||||
# Validate all priorities are integers
|
||||
for book_id, priority in book_priorities.items():
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
if not is_admin:
|
||||
book_priorities = {
|
||||
_resolve_viewer_download_id(str(book_id)): priority
|
||||
for book_id, priority in book_priorities.items()
|
||||
}
|
||||
owned_book_priorities = {}
|
||||
for book_id in book_priorities:
|
||||
task = backend.book_queue.get_task(str(book_id))
|
||||
if task is None:
|
||||
continue
|
||||
if not _task_owned_by_actor(
|
||||
task, actor_user_id=db_user_id, actor_username=actor_username
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
owned_book_priorities[book_id] = book_priorities[book_id]
|
||||
book_priorities = owned_book_priorities
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
|
||||
return jsonify({"error": "Failed to reorder queue"}), 500
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Reorder queue error: {e}")
|
||||
return jsonify({"error": "Could not reorder the queue"}), 500
|
||||
|
||||
@app.route("/api/queue/order", methods=["GET"])
|
||||
@login_required
|
||||
def api_queue_order() -> Response | tuple[Response, int]:
|
||||
"""Get current queue order for display.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of queued books with their order and priorities.
|
||||
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
queue_order = [
|
||||
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
|
||||
for item in queue_order
|
||||
if _queue_task_visible_to_actor(
|
||||
str(item.get("id", "")),
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"queue": queue_order})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
return jsonify({"error": "Could not read the queue order"}), 500
|
||||
|
||||
@app.route("/api/downloads/active", methods=["GET"])
|
||||
@login_required
|
||||
def api_active_downloads() -> Response | tuple[Response, int]:
|
||||
"""Get list of currently active downloads.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of active download book IDs.
|
||||
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
active_downloads = [
|
||||
_viewer_queue_task_id(task_id)
|
||||
for task_id in active_downloads
|
||||
if _queue_task_visible_to_actor(
|
||||
task_id,
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
return jsonify({"error": "Could not read the active downloads"}), 500
|
||||
@@ -0,0 +1,807 @@
|
||||
"""Release routes: listing, download, release sources and source records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import SearchFilters
|
||||
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
|
||||
from shelfmark.core.request_helpers import (
|
||||
coerce_bool,
|
||||
get_session_db_user_id,
|
||||
)
|
||||
from shelfmark.core.request_policy import (
|
||||
DEFAULT_SUPPORTED_CONTENT_TYPES,
|
||||
PolicyMode,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
normalize_source,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.utils import AUDIOBOOK_FORMATS
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.download import warmup
|
||||
from shelfmark.release_sources import BrowseRecord, Release, SourceUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
|
||||
|
||||
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
|
||||
_AUDIOBOOK_FORMAT_HINTS = frozenset(AUDIOBOOK_FORMATS)
|
||||
|
||||
|
||||
def _contains_audiobook_format_hint(value: Any) -> bool:
|
||||
"""Whether a string names an audiobook format as one of its words (m4b, mp3 and the like)."""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
tokens = [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
|
||||
return any(token in _AUDIOBOOK_FORMAT_HINTS for token in tokens)
|
||||
|
||||
|
||||
def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[str, bool]:
|
||||
"""Resolve release content type for policy checks and queue payload normalization."""
|
||||
extra = data.get("extra")
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
|
||||
explicit_content_type = data.get("content_type")
|
||||
if explicit_content_type is None:
|
||||
explicit_content_type = extra.get("content_type")
|
||||
if explicit_content_type is not None:
|
||||
return normalize_content_type(explicit_content_type), False
|
||||
|
||||
categories = extra.get("categories")
|
||||
if isinstance(categories, list):
|
||||
min_cat, max_cat = _AUDIOBOOK_CATEGORY_RANGE
|
||||
for raw_category in categories:
|
||||
try:
|
||||
category_id = int(raw_category)
|
||||
except TypeError, ValueError:
|
||||
continue
|
||||
if min_cat <= category_id <= max_cat:
|
||||
return "audiobook", True
|
||||
|
||||
candidates: list[Any] = [
|
||||
data.get("format"),
|
||||
extra.get("format"),
|
||||
extra.get("formats_display"),
|
||||
data.get("title"),
|
||||
]
|
||||
formats = extra.get("formats")
|
||||
if isinstance(formats, list):
|
||||
candidates.extend(formats)
|
||||
else:
|
||||
candidates.append(formats)
|
||||
|
||||
if any(_contains_audiobook_format_hint(candidate) for candidate in candidates):
|
||||
return "audiobook", True
|
||||
|
||||
capabilities = get_source_content_type_capabilities()
|
||||
supported = capabilities.get(normalize_source(source))
|
||||
if supported and len(supported) == 1:
|
||||
return normalize_content_type(next(iter(supported))), True
|
||||
|
||||
return "ebook", False
|
||||
|
||||
|
||||
def _policy_block_response(
|
||||
mode: PolicyMode, *, error: str = "Download not allowed by policy"
|
||||
) -> tuple[Response, int]:
|
||||
"""Build the 403 for a request the user's policy mode refuses, with the mode they need."""
|
||||
logger.debug(
|
||||
"download policy guard user=%s db_user_id=%s mode=%s",
|
||||
session.get("user_id"),
|
||||
session.get("db_user_id"),
|
||||
mode.value,
|
||||
)
|
||||
if mode == PolicyMode.BLOCKED:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": error,
|
||||
"code": "policy_blocked",
|
||||
"required_mode": PolicyMode.BLOCKED.value,
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": error,
|
||||
"code": "policy_requires_request",
|
||||
"required_mode": mode.value,
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
|
||||
# Modes under which the user never sees a release list: request_book asks for the
|
||||
# book as a whole and blocked gets nothing. request_release still lists releases,
|
||||
# since that user picks the one they ask for. Listing goes through the shared
|
||||
# source credentials (Prowlarr, torrent clients, scrapers), so the routes that
|
||||
# list have to refuse these two modes just as the download route does.
|
||||
_RELEASE_LISTING_REFUSED_MODES = frozenset({PolicyMode.REQUEST_BOOK, PolicyMode.BLOCKED})
|
||||
|
||||
|
||||
def _release_listing_block_response(mode: PolicyMode) -> tuple[Response, int]:
|
||||
"""Build the 403 for a release listing the user's policy mode refuses."""
|
||||
return _policy_block_response(mode, error="Release listing not allowed by policy")
|
||||
|
||||
|
||||
class _ReleasePolicy:
|
||||
"""Request-policy checks for the current session, shared by the release routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
load_policy_settings: Callable[[], dict[str, Any]],
|
||||
) -> None:
|
||||
"""Take the lookups as callables, so each check reads the current settings."""
|
||||
self._resolve_auth_mode = resolve_auth_mode
|
||||
self._get_user_db = get_user_db
|
||||
self._load_policy_settings = load_policy_settings
|
||||
|
||||
def mode_for_current_user(self, *, source: Any, content_type: Any) -> PolicyMode | None:
|
||||
"""Resolve policy mode for current session, or None when policy guard is bypassed."""
|
||||
user_db = self._get_user_db()
|
||||
auth_mode = self._resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return None
|
||||
if session.get("is_admin", False):
|
||||
return None
|
||||
if user_db is None:
|
||||
return None
|
||||
|
||||
global_settings = self._load_policy_settings()
|
||||
db_user_id = session.get("db_user_id")
|
||||
user_settings: dict[str, Any] | None = None
|
||||
if db_user_id is not None:
|
||||
try:
|
||||
user_settings = user_db.get_user_settings(int(db_user_id))
|
||||
except TypeError, ValueError:
|
||||
user_settings = None
|
||||
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
if not coerce_bool(effective.get("REQUESTS_ENABLED"), default=False):
|
||||
return None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"download policy resolve user=%s db_user_id=%s is_admin=%s source=%s content_type=%s mode=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
bool(session.get("is_admin", False)),
|
||||
source,
|
||||
content_type,
|
||||
resolved_mode.value,
|
||||
)
|
||||
return resolved_mode
|
||||
|
||||
def listing_refusal(self, *, source: Any, content_type: Any) -> PolicyMode | None:
|
||||
"""Return the policy mode when the current user may not list releases from `source`."""
|
||||
mode = self.mode_for_current_user(source=source, content_type=content_type)
|
||||
if mode is not None and mode in _RELEASE_LISTING_REFUSED_MODES:
|
||||
return mode
|
||||
return None
|
||||
|
||||
def sources_allowed_for_listing(
|
||||
self, sources: list[str], content_type: Any
|
||||
) -> tuple[list[str], PolicyMode | None]:
|
||||
"""Drop the sources the current user may not list releases from.
|
||||
|
||||
Returns the sources left and, when any was dropped, the mode of the first one
|
||||
dropped, which is what the response reports when nothing is left.
|
||||
"""
|
||||
allowed: list[str] = []
|
||||
refused_mode: PolicyMode | None = None
|
||||
for source_name in sources:
|
||||
refusal = self.listing_refusal(source=source_name, content_type=content_type)
|
||||
if refusal is None:
|
||||
allowed.append(source_name)
|
||||
elif refused_mode is None:
|
||||
refused_mode = refusal
|
||||
return allowed, refused_mode
|
||||
|
||||
def source_record_refusal(self, source_name: str) -> PolicyMode | None:
|
||||
"""Policy refusal for resolving one browse record of a release source.
|
||||
|
||||
The record's content type is not known before it is fetched, so the user is
|
||||
refused only when every content type the source supports is refused.
|
||||
"""
|
||||
capabilities = get_source_content_type_capabilities()
|
||||
content_types = capabilities.get(normalize_source(source_name)) or set(
|
||||
DEFAULT_SUPPORTED_CONTENT_TYPES
|
||||
)
|
||||
refused_mode: PolicyMode | None = None
|
||||
for content_type in sorted(content_types):
|
||||
refusal = self.listing_refusal(source=source_name, content_type=content_type)
|
||||
if refusal is None:
|
||||
return None
|
||||
if refused_mode is None:
|
||||
refused_mode = refusal
|
||||
return refused_mode
|
||||
|
||||
def payload_listing_gate(self, data: dict[str, Any]) -> tuple[Response, int] | None:
|
||||
"""Policy gate for routes that take a release payload, shared with the inspect route."""
|
||||
source = data["source"]
|
||||
resolved_content_type, _inferred = _resolve_release_content_type(data, source)
|
||||
refusal = self.listing_refusal(source=source, content_type=resolved_content_type)
|
||||
if refusal is None:
|
||||
return None
|
||||
return _release_listing_block_response(refusal)
|
||||
|
||||
def resolve_download_user_context(
|
||||
self,
|
||||
db_user_id: Any,
|
||||
username: Any,
|
||||
on_behalf_of_user_id: Any,
|
||||
) -> tuple[Any, Any, tuple[Response, int] | None]:
|
||||
"""Resolve download queue user context, including optional admin on-behalf overrides."""
|
||||
user_db = self._get_user_db()
|
||||
if on_behalf_of_user_id in (None, ""):
|
||||
return db_user_id, username, None
|
||||
|
||||
if not session.get("is_admin", False):
|
||||
return db_user_id, username, (jsonify({"error": "Admin required"}), 403)
|
||||
|
||||
if user_db is None:
|
||||
return db_user_id, username, (jsonify({"error": "User database unavailable"}), 503)
|
||||
|
||||
try:
|
||||
target_user_id = int(on_behalf_of_user_id)
|
||||
except TypeError, ValueError:
|
||||
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
|
||||
|
||||
if target_user_id <= 0:
|
||||
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
|
||||
|
||||
target_user = user_db.get_user(user_id=target_user_id)
|
||||
if not target_user:
|
||||
return db_user_id, username, (jsonify({"error": "User not found"}), 404)
|
||||
|
||||
return target_user["id"], target_user["username"], None
|
||||
|
||||
|
||||
def _parse_search_filters_from_request() -> SearchFilters:
|
||||
"""Parse direct/source browse filters from query parameters."""
|
||||
return SearchFilters(
|
||||
isbn=request.args.getlist("isbn"),
|
||||
author=request.args.getlist("author"),
|
||||
title=request.args.getlist("title"),
|
||||
lang=request.args.getlist("lang"),
|
||||
sort=request.args.get("sort"),
|
||||
content=request.args.getlist("content"),
|
||||
format=request.args.getlist("format"),
|
||||
)
|
||||
|
||||
|
||||
def _build_source_query_book(query_text: str, filters: SearchFilters) -> BookMetadata:
|
||||
"""Build a synthetic book context for source-native browse searches."""
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
author_values = [value.strip() for value in (filters.author or []) if str(value).strip()]
|
||||
title_values = [value.strip() for value in (filters.title or []) if str(value).strip()]
|
||||
isbn_values = [value.strip() for value in (filters.isbn or []) if str(value).strip()]
|
||||
title = (
|
||||
title_values[0]
|
||||
if title_values
|
||||
else query_text
|
||||
or (isbn_values[0] if isbn_values else "")
|
||||
or (author_values[0] if author_values else "Direct Search")
|
||||
)
|
||||
author = author_values[0] if author_values else ""
|
||||
|
||||
return BookMetadata(
|
||||
provider="manual",
|
||||
provider_id=query_text or title,
|
||||
provider_display_name="Manual Search",
|
||||
title=title,
|
||||
search_title=title,
|
||||
search_author=author or None,
|
||||
authors=author_values,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_browse_record(record: BrowseRecord) -> dict:
|
||||
"""Serialize a source-native browse record for the frontend."""
|
||||
result = {key: value for key, value in record.__dict__.items() if value is not None}
|
||||
|
||||
preview = result.get("preview")
|
||||
if isinstance(preview, str) and preview:
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
|
||||
result["preview"] = transform_cover_url(preview, record.id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_release(release: Release) -> dict:
|
||||
"""Serialize a release for the frontend, normalizing preview URLs."""
|
||||
from dataclasses import asdict
|
||||
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
|
||||
result = asdict(release)
|
||||
extra = result.get("extra")
|
||||
if isinstance(extra, dict):
|
||||
preview = extra.get("preview")
|
||||
if isinstance(preview, str) and preview:
|
||||
extra = dict(extra)
|
||||
extra["preview"] = transform_cover_url(preview, release.source_id)
|
||||
result["extra"] = extra
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def register_release_routes(
|
||||
app: Flask,
|
||||
login_required: Callable[..., Any],
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
load_policy_settings: Callable[[], dict[str, Any]],
|
||||
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
|
||||
attach_library_ownership: Callable[[Any, dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Register the release listing, download, source list and source record routes."""
|
||||
policy = _ReleasePolicy(
|
||||
resolve_auth_mode=resolve_auth_mode,
|
||||
get_user_db=get_user_db,
|
||||
load_policy_settings=load_policy_settings,
|
||||
)
|
||||
|
||||
register_release_inspect_routes(app, login_required, policy_gate=policy.payload_listing_gate)
|
||||
|
||||
@app.route("/api/releases/download", methods=["POST"])
|
||||
@login_required
|
||||
def api_download_release() -> Response | tuple[Response, int]:
|
||||
"""Queue a release for download.
|
||||
|
||||
This endpoint is used when downloading from the ReleaseModal where the
|
||||
frontend already has all the release data from the search results.
|
||||
|
||||
Request Body (JSON):
|
||||
source (str): Release source (e.g., "direct_download")
|
||||
source_id (str): ID within the source (e.g., AA MD5 hash)
|
||||
title (str): Book title
|
||||
format (str, optional): File format
|
||||
size (str, optional): Human-readable size
|
||||
extra (dict, optional): Additional metadata
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status object indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
if "source_id" not in data:
|
||||
return jsonify({"error": "source_id is required"}), 400
|
||||
if "source" not in data:
|
||||
return jsonify({"error": "source is required"}), 400
|
||||
|
||||
source = data["source"]
|
||||
resolved_content_type, inferred_content_type = _resolve_release_content_type(
|
||||
data, source
|
||||
)
|
||||
policy_mode = policy.mode_for_current_user(
|
||||
source=source,
|
||||
content_type=resolved_content_type,
|
||||
)
|
||||
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
|
||||
return _policy_block_response(policy_mode)
|
||||
|
||||
release_payload = data
|
||||
if inferred_content_type and data.get("content_type") is None:
|
||||
release_payload = dict(data)
|
||||
release_payload["content_type"] = resolved_content_type
|
||||
|
||||
logger.info(
|
||||
"Download request received. keys=%s downloads=%s extra.downloads=%s",
|
||||
list(data.keys()),
|
||||
data.get("downloads"),
|
||||
data.get("extra", {}).get("downloads")
|
||||
if isinstance(data.get("extra"), dict)
|
||||
else None,
|
||||
)
|
||||
|
||||
priority = data.get("priority", 0)
|
||||
# Per-user download overrides
|
||||
db_user_id = session.get("db_user_id")
|
||||
_username = session.get("user_id")
|
||||
db_user_id, _username, on_behalf_error = policy.resolve_download_user_context(
|
||||
db_user_id,
|
||||
_username,
|
||||
data.get("on_behalf_of_user_id"),
|
||||
)
|
||||
if on_behalf_error:
|
||||
return on_behalf_error
|
||||
success, error_msg = backend.queue_release(
|
||||
release_payload,
|
||||
priority,
|
||||
user_id=db_user_id,
|
||||
username=_username,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "queued", "priority": priority})
|
||||
return jsonify({"error": error_msg or "Failed to queue release"}), 500
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Release download error: {e}")
|
||||
return jsonify({"error": "Could not queue the release"}), 500
|
||||
|
||||
@app.route("/api/releases", methods=["GET"])
|
||||
@login_required
|
||||
def api_releases() -> Response | tuple[Response, int]:
|
||||
"""Search for downloadable releases of a book.
|
||||
|
||||
This endpoint takes book metadata and searches available release sources
|
||||
(e.g., Anna's Archive, Libgen) for downloadable files.
|
||||
|
||||
Query Parameters:
|
||||
provider (str): Metadata provider name (required)
|
||||
book_id (str): Book ID from metadata provider (required)
|
||||
source (str): Release source to search (optional, default: all)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with list of available releases.
|
||||
|
||||
"""
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
|
||||
from shelfmark.core.release_search import search_source_releases
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_registered,
|
||||
)
|
||||
from shelfmark.release_sources import (
|
||||
apply_column_visibility,
|
||||
browse_record_to_book_metadata,
|
||||
get_source,
|
||||
list_available_sources,
|
||||
serialize_column_config,
|
||||
source_results_are_releases,
|
||||
)
|
||||
|
||||
def _search_source_releases(
|
||||
source_name: str, search_book: BookMetadata
|
||||
) -> tuple[Any | None, list[Any], str | None]:
|
||||
"""Search one source and return any error message instead of raising."""
|
||||
return search_source_releases(
|
||||
source_name,
|
||||
search_book,
|
||||
languages=(
|
||||
browse_filters.lang if source_query_filters is not None else languages
|
||||
),
|
||||
manual_query=(query_text if source_query_filters is not None else manual_query),
|
||||
indexers=indexers,
|
||||
expand_search=expand_search,
|
||||
content_type=content_type,
|
||||
source_filters=source_query_filters,
|
||||
user_id=db_user_id,
|
||||
include_error_detail=resolve_status_scope(require_authenticated=False)[0],
|
||||
)
|
||||
|
||||
provider = request.args.get("provider", "").strip()
|
||||
book_id = request.args.get("book_id", "").strip()
|
||||
source_filter = request.args.get("source", "").strip()
|
||||
query_text = request.args.get("query", "").strip()
|
||||
# Accept title/author from frontend to avoid re-fetching metadata
|
||||
title_param = request.args.get("title", "").strip()
|
||||
author_param = request.args.get("author", "").strip()
|
||||
expand_search = request.args.get("expand_search", "").lower() == "true"
|
||||
# Accept language codes for filtering (comma-separated)
|
||||
languages_param = request.args.get("languages", "").strip()
|
||||
languages = (
|
||||
[lang.strip() for lang in languages_param.split(",") if lang.strip()]
|
||||
if languages_param
|
||||
else None
|
||||
)
|
||||
# Without an explicit filter the plan falls back to this user's default languages.
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
# Content type for audiobook vs ebook search
|
||||
content_type = request.args.get("content_type", "ebook").strip()
|
||||
|
||||
manual_query = request.args.get("manual_query", "").strip()
|
||||
|
||||
# Accept indexer names for Prowlarr filtering (comma-separated)
|
||||
indexers_param = request.args.get("indexers", "").strip()
|
||||
indexers = (
|
||||
[idx.strip() for idx in indexers_param.split(",") if idx.strip()]
|
||||
if indexers_param
|
||||
else None
|
||||
)
|
||||
browse_filters = _parse_search_filters_from_request()
|
||||
has_browse_filters = bool(query_text or any(vars(browse_filters).values()))
|
||||
|
||||
source_query_filters = None
|
||||
is_source_provider = bool(provider) and source_results_are_releases(provider)
|
||||
|
||||
if not provider or not book_id:
|
||||
if not source_filter or not has_browse_filters:
|
||||
return jsonify(
|
||||
{"error": "Parameters 'provider' and 'book_id' are required"}
|
||||
), 400
|
||||
if not source_results_are_releases(source_filter):
|
||||
return jsonify(
|
||||
{"error": f"Source does not support browse release search: {source_filter}"}
|
||||
), 400
|
||||
source_query_filters = browse_filters
|
||||
|
||||
# Determine which release sources to search
|
||||
if source_filter:
|
||||
sources_to_search = [source_filter]
|
||||
elif is_source_provider:
|
||||
# Source-backed browse flows stay within the source that produced the record.
|
||||
sources_to_search = [provider]
|
||||
else:
|
||||
# Search only enabled sources
|
||||
sources_to_search = [
|
||||
src["name"] for src in list_available_sources() if src["enabled"]
|
||||
]
|
||||
|
||||
# The policy gate comes before anything is asked of a source: listing goes
|
||||
# through the shared source credentials, so a user whose policy never shows
|
||||
# a release list (request_book, blocked) must not reach them.
|
||||
sources_to_search, refused_mode = policy.sources_allowed_for_listing(
|
||||
sources_to_search, content_type
|
||||
)
|
||||
if not sources_to_search and refused_mode is not None:
|
||||
return _release_listing_block_response(refused_mode)
|
||||
|
||||
book: BookMetadata
|
||||
|
||||
if source_query_filters is not None:
|
||||
book = _build_source_query_book(query_text, browse_filters)
|
||||
elif is_source_provider:
|
||||
# Source-backed browse flows can reopen the release modal with provider=<source name>.
|
||||
# In that flow, treat the source-native record as release-search context instead of
|
||||
# requiring a metadata provider registration.
|
||||
# A source named in `source` was gated above. When provider names another one,
|
||||
# its record lookup needs the refusal the record route applies.
|
||||
if normalize_source(provider) not in {
|
||||
normalize_source(s) for s in sources_to_search
|
||||
}:
|
||||
refused_record_mode = policy.source_record_refusal(provider)
|
||||
if refused_record_mode is not None:
|
||||
return _release_listing_block_response(refused_record_mode)
|
||||
source = get_source(provider)
|
||||
direct_record = source.get_record(book_id)
|
||||
if direct_record is None:
|
||||
return jsonify({"error": "Book not found in release source"}), 404
|
||||
|
||||
book = browse_record_to_book_metadata(
|
||||
direct_record,
|
||||
title_override=title_param or None,
|
||||
author_override=author_param or None,
|
||||
)
|
||||
elif provider == "manual":
|
||||
resolved_title = title_param or manual_query or "Manual Search"
|
||||
resolved_author = author_param or ""
|
||||
# The release modal sends `authors.join(', ')` as `author`, so the commas here
|
||||
# are joins between contributors, not part of one name. This split is the only
|
||||
# place that knows that, so `search_author` comes from it rather than from the
|
||||
# joined text - see issue #1252.
|
||||
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
|
||||
|
||||
book = BookMetadata(
|
||||
provider="manual",
|
||||
provider_id=book_id,
|
||||
provider_display_name="Manual Search",
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=authors[0] if authors else None,
|
||||
authors=authors,
|
||||
)
|
||||
else:
|
||||
if not is_provider_registered(provider):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
|
||||
|
||||
# Get book metadata from provider
|
||||
kwargs = get_provider_kwargs(provider)
|
||||
prov = get_provider(provider, **kwargs)
|
||||
resolved_book = prov.get_book(book_id)
|
||||
|
||||
if not resolved_book:
|
||||
return jsonify({"error": "Book not found in metadata provider"}), 404
|
||||
book = resolved_book
|
||||
|
||||
# Override title from frontend if available (search results may have better data)
|
||||
# Note: We intentionally DON'T override authors here - get_book() now returns
|
||||
# filtered authors (primary authors only, excluding translators/narrators),
|
||||
# which gives better release search results than the unfiltered search data
|
||||
if title_param:
|
||||
if title_param != book.title:
|
||||
# A different title is one the search result carried and the book
|
||||
# record does not, typically a translation's ("Kinder des Nebels" for
|
||||
# "Mistborn: The Final Empire"). The provider's search_title derives
|
||||
# from the original and would otherwise win the release search.
|
||||
book.search_title = title_param
|
||||
book.title = title_param
|
||||
|
||||
# Search each source for releases.
|
||||
#
|
||||
# Under a wall-clock budget: this endpoint is synchronous, and the bypass path it
|
||||
# can reach used to be allowed minutes per URL with nothing bounding the request
|
||||
# as a whole. A search that ran into an unsolvable protection challenge therefore
|
||||
# outlived every reverse proxy in front of it and surfaced to the user as
|
||||
# "Server unavailable (504)" - a gateway timeout that blames their proxy for a
|
||||
# challenge failure. The budget is shared across sources, so a stuck first source
|
||||
# cannot spend the whole request on its own. See issue #1276.
|
||||
all_releases = []
|
||||
errors = []
|
||||
source_instances = {} # Keep source instances for column config
|
||||
|
||||
# A real search is under way, so a warm-up still sitting on its start-up delay
|
||||
# should stand down rather than queue its throwaway solve in front of this one.
|
||||
warmup.note_user_search()
|
||||
|
||||
with search_deadline.search_deadline():
|
||||
for index, source_name in enumerate(sources_to_search):
|
||||
if search_deadline.expired():
|
||||
logger.warning("Release search budget spent; %s not searched", source_name)
|
||||
errors.append(f"{source_name}: {search_deadline.deadline_message()}")
|
||||
continue
|
||||
|
||||
with search_deadline.unit_deadline(waiting=len(sources_to_search) - index - 1):
|
||||
source, releases, error = _search_source_releases(source_name, book)
|
||||
if source is not None:
|
||||
source_instances[source_name] = source
|
||||
all_releases.extend(releases)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
|
||||
# Convert Release objects to dicts
|
||||
releases_data = [_serialize_release(release) for release in all_releases]
|
||||
|
||||
# Get column config from the first source searched
|
||||
# Reuse the same instance to get any dynamic data (e.g., online_servers for IRC)
|
||||
column_config = None
|
||||
if sources_to_search and sources_to_search[0] in source_instances:
|
||||
try:
|
||||
first_source = source_instances[sources_to_search[0]]
|
||||
column_config = serialize_column_config(
|
||||
apply_column_visibility(
|
||||
first_source.get_column_config(),
|
||||
content_type=normalize_content_type(content_type),
|
||||
is_setting_enabled=lambda key: bool(
|
||||
app_config.get(key, True, user_id=db_user_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.warning("Failed to get column config: %s", e)
|
||||
|
||||
# Convert book to dict and transform cover_url
|
||||
book_dict = asdict(book)
|
||||
attach_library_ownership(book, book_dict)
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
|
||||
if book_dict.get("cover_url"):
|
||||
cache_id = f"{provider}_{book_id}"
|
||||
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
|
||||
|
||||
search_info = {}
|
||||
for source_name, source_instance in source_instances.items():
|
||||
info: dict[str, str | int | None] = {}
|
||||
if (
|
||||
hasattr(source_instance, "last_search_type")
|
||||
and source_instance.last_search_type
|
||||
):
|
||||
info["search_type"] = source_instance.last_search_type
|
||||
if hasattr(source_instance, "total_results"):
|
||||
info["total_results"] = source_instance.total_results
|
||||
if info:
|
||||
search_info[source_name] = info
|
||||
|
||||
response = {
|
||||
"releases": releases_data,
|
||||
"book": book_dict,
|
||||
"sources_searched": sources_to_search,
|
||||
"column_config": column_config,
|
||||
"search_info": search_info,
|
||||
}
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
# If no releases found and there were errors, return 503 with the first
|
||||
# source failure message so direct-mode source query searches surface the
|
||||
# same unavailable-state messaging as release modal searches.
|
||||
if not releases_data and errors:
|
||||
# Use the first error message (typically the most relevant)
|
||||
error_message = errors[0]
|
||||
# Strip the source prefix if present (e.g., "direct_download: message" -> "message")
|
||||
if ": " in error_message:
|
||||
error_message = error_message.split(": ", 1)[1]
|
||||
return jsonify({"error": error_message}), 503
|
||||
|
||||
return jsonify(response)
|
||||
except SourceUnavailableError as e:
|
||||
logger.warning("Release search unavailable: %s", e)
|
||||
return jsonify({"error": str(e)}), 503
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Releases search error: {e}")
|
||||
return jsonify({"error": "Could not search for releases"}), 500
|
||||
|
||||
@app.route("/api/release-sources", methods=["GET"])
|
||||
@login_required
|
||||
def api_release_sources() -> Response | tuple[Response, int]:
|
||||
"""Get available release sources from the plugin registry.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON list of available release sources.
|
||||
|
||||
"""
|
||||
try:
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
sources = list_available_sources()
|
||||
return jsonify(sources)
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Release sources error: {e}")
|
||||
return jsonify({"error": "Could not list the release sources"}), 500
|
||||
|
||||
@app.route("/api/release-sources/<source_name>/records/<path:record_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_release_source_record(
|
||||
source_name: str, record_id: str
|
||||
) -> Response | tuple[Response, int]:
|
||||
"""Resolve a source-native browse record for a release source."""
|
||||
try:
|
||||
from shelfmark.release_sources import get_source
|
||||
|
||||
source = get_source(source_name)
|
||||
refused_mode = policy.source_record_refusal(source_name)
|
||||
if refused_mode is not None:
|
||||
return _release_listing_block_response(refused_mode)
|
||||
record = source.get_record(record_id)
|
||||
if record is None:
|
||||
return jsonify({"error": "Record not found"}), 404
|
||||
return jsonify(_serialize_browse_record(record))
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Unknown release source: {source_name}"}), 400
|
||||
except SourceUnavailableError as e:
|
||||
logger.warning("Release source record unavailable: %s", e)
|
||||
return jsonify({"error": str(e)}), 503
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Release source record error: {e}")
|
||||
return jsonify({"error": "Could not resolve the record"}), 500
|
||||
@@ -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):
|
||||
|
||||
@@ -70,6 +70,7 @@ REQUEST_POLICY_KEYS = frozenset(
|
||||
"REQUEST_POLICY_RULES",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
"REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -304,6 +305,10 @@ def _prepare_request_create_arguments(
|
||||
user_db: UserDB,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Check a request payload against the policy and build create_request's arguments.
|
||||
|
||||
A book-level request in Download mode becomes a pending one when the actor may ask for it.
|
||||
"""
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
msg = "User identity is unavailable for request workflow"
|
||||
@@ -404,6 +409,21 @@ def _prepare_request_create_arguments(
|
||||
)
|
||||
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if (
|
||||
resolved_mode == PolicyMode.DOWNLOAD
|
||||
and requested_level == "book"
|
||||
and release_data is None
|
||||
and (
|
||||
bool(session.get("is_admin", False))
|
||||
or coerce_bool(
|
||||
effective.get("REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD"), default=False
|
||||
)
|
||||
)
|
||||
):
|
||||
# Everything can be downloaded here, but the user asked for the book itself (nothing to
|
||||
# download yet, or no source has it): leave it for an admin instead of refusing it.
|
||||
resolved_mode = PolicyMode.REQUEST_BOOK
|
||||
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
|
||||
msg = "Policy requires book-level requests"
|
||||
raise RequestServiceError(
|
||||
@@ -571,6 +591,7 @@ def register_request_routes(
|
||||
|
||||
@app.route("/api/request-policy", methods=["GET"])
|
||||
def api_request_policy() -> ResponseReturnValue:
|
||||
"""The signed-in user's request policy, as the frontend needs it to label actions."""
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -624,6 +645,9 @@ def register_request_routes(
|
||||
"requests_enabled": requests_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_notes": coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
|
||||
"allow_book_request_with_download": coerce_bool(
|
||||
effective.get("REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD"), default=False
|
||||
),
|
||||
"defaults": {
|
||||
"ebook": (
|
||||
default_ebook_mode.value
|
||||
@@ -915,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
|
||||
@@ -931,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)
|
||||
|
||||
@@ -1080,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,6 +18,7 @@ from shelfmark.core.request_validation import (
|
||||
normalize_policy_mode,
|
||||
validate_request_level_payload,
|
||||
)
|
||||
from shelfmark.core.status_messages import client_problem
|
||||
|
||||
MAX_REQUEST_NOTE_LENGTH = 1000
|
||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||
@@ -271,9 +272,12 @@ def sync_delivery_states_from_queue_status(
|
||||
if isinstance(raw_status_message, str) and raw_status_message.strip()
|
||||
else "Download failed"
|
||||
)
|
||||
# Reopening clears the release, so note on the request that this one failed:
|
||||
# the next search skips it.
|
||||
reopened = user_db.reopen_failed_request(
|
||||
request_id,
|
||||
failure_reason=failure_reason,
|
||||
remember_failed_release=not client_problem(failure_reason),
|
||||
)
|
||||
if reopened is not None:
|
||||
updated.append(reopened)
|
||||
@@ -541,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)
|
||||
@@ -571,7 +578,19 @@ def reopen_request(
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(msg, status_code=409, code="duplicate_pending_request")
|
||||
|
||||
reopened = user_db.reopen_rejected_request(request_id, admin_note=normalized_admin_note)
|
||||
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"
|
||||
raise RequestServiceError(msg, status_code=409, code="stale_transition")
|
||||
@@ -700,8 +719,14 @@ def reopen_failed_request(
|
||||
request_id: int,
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
|
||||
"""Reopen a failed fulfilled request so admins can re-approve with a new release.
|
||||
|
||||
The release that failed is noted on the request, so the next search does not pick it again,
|
||||
unless the failure was the client being unreachable or refusing the login, which says nothing
|
||||
about the release.
|
||||
"""
|
||||
return user_db.reopen_failed_request(
|
||||
request_id,
|
||||
failure_reason=failure_reason,
|
||||
remember_failed_release=not client_problem(failure_reason),
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Socket.IO handlers: connect, disconnect and a manual status request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from flask import request
|
||||
from flask_socketio import emit
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.viewer_redaction import redact_status
|
||||
from shelfmark.download import orchestrator as backend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
from shelfmark.api.websocket import WebSocketManager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
|
||||
|
||||
def _get_request_sid() -> str | None:
|
||||
"""Return the Socket.IO session id for the active request when available."""
|
||||
sid = getattr(request, "sid", None)
|
||||
return sid if isinstance(sid, str) and sid else None
|
||||
|
||||
|
||||
def register_socket_handlers(
|
||||
socketio: SocketIO,
|
||||
*,
|
||||
ws_manager: WebSocketManager,
|
||||
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
|
||||
) -> None:
|
||||
"""Register the connect, disconnect and request_status handlers.
|
||||
|
||||
``resolve_status_scope`` stays in ``main.py`` because the HTTP routes use it too.
|
||||
"""
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect() -> None:
|
||||
"""Handle client connection."""
|
||||
logger.info("WebSocket client connected")
|
||||
|
||||
# Track the connection (triggers warmup callbacks on first connect)
|
||||
ws_manager.client_connected()
|
||||
|
||||
# Join appropriate room based on authenticated user session
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
sid = _get_request_sid()
|
||||
if sid is None:
|
||||
logger.warning("Socket.IO connect event missing sid")
|
||||
return
|
||||
ws_manager.join_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
# Send initial status to the newly connected client (filtered)
|
||||
try:
|
||||
if not can_access_status:
|
||||
emit("status_update", {})
|
||||
return
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
emit("status_update", status if is_admin else redact_status(status))
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("Error sending initial status")
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect() -> None:
|
||||
"""Handle client disconnection."""
|
||||
logger.info("WebSocket client disconnected")
|
||||
|
||||
# Leave room
|
||||
sid = _get_request_sid()
|
||||
if sid is not None:
|
||||
ws_manager.leave_user_room(sid)
|
||||
|
||||
# Track the disconnection
|
||||
ws_manager.client_disconnected()
|
||||
|
||||
@socketio.on("request_status")
|
||||
def handle_status_request() -> None:
|
||||
"""Handle manual status request from client."""
|
||||
try:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
sid = _get_request_sid()
|
||||
if sid is None:
|
||||
logger.warning("Socket.IO request_status event missing sid")
|
||||
emit("status_update", {})
|
||||
return
|
||||
ws_manager.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
if not can_access_status:
|
||||
emit("status_update", {})
|
||||
return
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
emit("status_update", status if is_admin else redact_status(status))
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("Error handling status request")
|
||||
emit("error", {"message": "Failed to get status"})
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Plain wording for a download that failed or stopped: what happened and what comes next.
|
||||
|
||||
The saved message is a log line ("Peer transfer failed: Errored"). Auto-download reads it to
|
||||
tell a failed release from an interrupted one, so it is never rewritten; only what the activity
|
||||
list shows is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.request_helpers import coerce_int
|
||||
|
||||
FAILURE_RETRY_MINUTES_SETTING = "AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES"
|
||||
|
||||
_SHOWN_FOR = frozenset({"error", "cancelled"})
|
||||
|
||||
# What a download records when its client could not be reached or refused Shelfmark's login.
|
||||
# Bare "forbidden" or "unauthorized" is not on the list: a dead .torrent link fails that way too,
|
||||
# and that one is the release's fault. A client's login failure is reworded to say so.
|
||||
# Neither says anything about the release: the same one is fine once the client is back or its
|
||||
# settings are fixed, and it may already be downloading there.
|
||||
_CLIENT_PROBLEM_MARKERS = (
|
||||
"failed to connect",
|
||||
"cannot connect",
|
||||
"connection refused",
|
||||
"connection aborted",
|
||||
"authentication failed",
|
||||
)
|
||||
|
||||
|
||||
def client_problem(message: object) -> bool:
|
||||
"""Whether a failure message blames the download client rather than the release."""
|
||||
text = str(message or "").lower()
|
||||
return any(marker in text for marker in _CLIENT_PROBLEM_MARKERS)
|
||||
|
||||
|
||||
def _next_step(*, request_linked: bool, retry_minutes: int) -> str:
|
||||
if request_linked and retry_minutes > 0:
|
||||
return f" Another copy will be tried in about {retry_minutes} minutes."
|
||||
if request_linked:
|
||||
return " Use Retry to try again, or reopen the request to try another copy."
|
||||
return " Use Retry to try again, or pick another copy."
|
||||
|
||||
|
||||
def explain(
|
||||
message: str | None,
|
||||
*,
|
||||
request_linked: bool,
|
||||
retry_minutes: int | None = None,
|
||||
) -> str | None:
|
||||
"""The message in plain words with the next step, or it unchanged when nothing is known."""
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
return message
|
||||
text = message.strip()
|
||||
lowered = text.lower()
|
||||
minutes = (
|
||||
retry_minutes
|
||||
if retry_minutes is not None
|
||||
else coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
|
||||
)
|
||||
nxt = _next_step(request_linked=request_linked, retry_minutes=minutes)
|
||||
|
||||
if lowered.startswith("superseded"):
|
||||
return "This book was already delivered by another download."
|
||||
if lowered.startswith("interrupted"):
|
||||
return "Interrupted by a restart. Use Retry to run it again."
|
||||
if lowered.startswith("peer transfer failed"):
|
||||
detail = text.split(":", 1)[1].strip() if ":" in text else ""
|
||||
suffix = f" ({detail})" if detail else ""
|
||||
return f"The Soulseek peer dropped the transfer{suffix}." + nxt
|
||||
if lowered.startswith("failed to add to slskd"):
|
||||
if "direct or indirect" in lowered:
|
||||
return (
|
||||
"Could not reach the Soulseek peer, directly or through the Soulseek server "
|
||||
"(tried twice)." + nxt
|
||||
)
|
||||
if '("' in text and "timed out" not in lowered:
|
||||
# slskd gave a reason, and it is not a timeout.
|
||||
return "slskd would not queue the download (tried twice)." + nxt
|
||||
# A timeout, or the bare 500 an older attempt saved before slskd's reason was kept; every
|
||||
# one of those that slskd's own log explained was a peer that did not answer in time.
|
||||
return "The Soulseek peer did not answer slskd's queue request (tried twice)." + nxt
|
||||
if lowered.startswith("download failed:"):
|
||||
detail = text.split(":", 1)[1].strip()
|
||||
return f"The download hit an internal error ({detail})." + nxt
|
||||
if lowered.startswith("retrying after"):
|
||||
return "Retrying automatically after a failed download."
|
||||
if lowered.startswith("download stalled"):
|
||||
return "Nothing was downloading, so it was stopped." + nxt
|
||||
if lowered.startswith("no seeders"):
|
||||
return text.rstrip(".") + "; the torrent was removed." + nxt
|
||||
if lowered.startswith("all download sources failed"):
|
||||
return "None of the download sources could provide this book." + nxt
|
||||
return text
|
||||
|
||||
|
||||
def explain_request_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""The request rows with ``last_failure_reason`` in plain words (copies; the stored text stays)."""
|
||||
minutes = coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
|
||||
explained: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
reason = row.get("last_failure_reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
explained.append(row)
|
||||
continue
|
||||
# A request that was reopened after a restart is searched again by the next pass.
|
||||
text = (
|
||||
"Interrupted by a restart; it will be tried again."
|
||||
if reason.strip().lower().startswith("interrupted by a restart")
|
||||
else explain(reason, request_linked=True, retry_minutes=minutes)
|
||||
)
|
||||
explained.append({**row, "last_failure_reason": text})
|
||||
return explained
|
||||
|
||||
|
||||
def explain_buckets(status: dict[str, dict[str, Any]]) -> None:
|
||||
"""Rewrite the messages of the error and cancelled entries of a status dict, in place."""
|
||||
minutes = coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
|
||||
for bucket in _SHOWN_FOR:
|
||||
entries = status.get(bucket, {})
|
||||
for task_id, payload in list(entries.items()):
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
# A copy, so a payload the queue hands out again is never explained twice.
|
||||
entries[task_id] = {
|
||||
**payload,
|
||||
"status_message": explain(
|
||||
payload.get("status_message"),
|
||||
request_linked=bool(payload.get("request_id")),
|
||||
retry_minutes=minutes,
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"""System routes: the config and health endpoints, the debug tools, the frontend files and the SPA fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
|
||||
|
||||
from flask import jsonify, send_file, send_from_directory, session
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.config.env import BUILD_VERSION, RELEASE_VERSION
|
||||
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.download import orchestrator as backend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
|
||||
|
||||
_BASE_TAG = '<base href="/" data-shelfmark-base />'
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
"""Raise RuntimeError with the message, for raising inside a try block (ruff TRY301)."""
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
class DebugHooks(NamedTuple):
|
||||
"""What the DEBUG-only routes need from the app: stop the GUI, then run the debug script.
|
||||
|
||||
``run_script`` waits a second for the GUI to stop before it starts the script.
|
||||
"""
|
||||
|
||||
stop_gui: Callable[[], None]
|
||||
run_script: Callable[[], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
def register_system_routes(
|
||||
app: Flask,
|
||||
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
resolve_db_user_id: Callable[[], int | None],
|
||||
get_base_path: Callable[[], str],
|
||||
get_frontend_dist: Callable[[], Path],
|
||||
debug_hooks: DebugHooks | None,
|
||||
) -> None:
|
||||
"""Register the system routes, the frontend files and the catch-all for client-side routes.
|
||||
|
||||
``login_required`` is the app's decorator. ``resolve_auth_mode``, ``resolve_db_user_id``,
|
||||
``get_base_path`` and ``get_frontend_dist`` are called per request so a runtime patch of any of them still takes
|
||||
effect. ``/api/debug`` and ``/api/restart`` are registered only when ``debug_hooks`` is given.
|
||||
"""
|
||||
|
||||
def admin_required(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
"""Require an authenticated admin session for a Flask route.
|
||||
|
||||
Builds on `login_required`, so auth mode "none" (no users, the single
|
||||
operator owns the instance) still has access.
|
||||
"""
|
||||
|
||||
@login_required
|
||||
@wraps(f)
|
||||
def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
|
||||
"""Return 403 to a signed-in user who is not an admin, otherwise run the route."""
|
||||
if resolve_auth_mode() != "none" and not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def _base_href() -> str:
|
||||
"""The href for the page's base tag: the configured base path with a trailing slash."""
|
||||
base_path = get_base_path()
|
||||
if not base_path:
|
||||
return "/"
|
||||
return f"{base_path}/"
|
||||
|
||||
def _serve_index_html() -> Response:
|
||||
"""Serve index.html with an adjusted base tag for subpath deployments."""
|
||||
frontend_dist = get_frontend_dist()
|
||||
index_path = frontend_dist / "index.html"
|
||||
try:
|
||||
with index_path.open(encoding="utf-8") as handle:
|
||||
html = handle.read()
|
||||
except OSError:
|
||||
return send_from_directory(frontend_dist, "index.html")
|
||||
|
||||
if get_base_path() and _BASE_TAG in html:
|
||||
html = html.replace(_BASE_TAG, f'<base href="{_base_href()}" data-shelfmark-base />', 1)
|
||||
|
||||
return Response(html, mimetype="text/html")
|
||||
|
||||
# Serve frontend static files
|
||||
@app.route("/assets/<path:filename>")
|
||||
def serve_frontend_assets(filename: str) -> Response:
|
||||
"""Serve static assets from the built frontend."""
|
||||
return send_from_directory(get_frontend_dist() / "assets", filename)
|
||||
|
||||
@app.route("/")
|
||||
def index() -> Response:
|
||||
"""Serve the React frontend application.
|
||||
|
||||
Authentication is handled by the React app itself.
|
||||
"""
|
||||
return _serve_index_html()
|
||||
|
||||
@app.route("/theme-init.js")
|
||||
def theme_init_js() -> Response:
|
||||
"""Serve the blocking theme-init script."""
|
||||
return send_from_directory(
|
||||
get_frontend_dist(), "theme-init.js", mimetype="application/javascript"
|
||||
)
|
||||
|
||||
@app.route("/logo.png")
|
||||
def logo() -> Response:
|
||||
"""Serve logo from built frontend assets."""
|
||||
return send_from_directory(get_frontend_dist(), "logo.png", mimetype="image/png")
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
@app.route("/favico<path:_>")
|
||||
def favicon(_: Any = None) -> Response:
|
||||
"""Serve favicon from built frontend assets."""
|
||||
return send_from_directory(
|
||||
get_frontend_dist(), "favicon.ico", mimetype="image/vnd.microsoft.icon"
|
||||
)
|
||||
|
||||
@app.route("/api/config", methods=["GET"])
|
||||
@login_required
|
||||
def api_config() -> Response | tuple[Response, int]:
|
||||
"""Get application configuration for frontend.
|
||||
|
||||
Uses the dynamic config singleton to ensure settings changes
|
||||
are reflected without requiring a container restart.
|
||||
"""
|
||||
try:
|
||||
from shelfmark.config.env import _is_config_dir_writable
|
||||
from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete
|
||||
from shelfmark.metadata_providers import (
|
||||
get_provider_default_sort,
|
||||
get_provider_search_fields,
|
||||
get_provider_sort_options,
|
||||
)
|
||||
|
||||
db_user_id = resolve_db_user_id()
|
||||
|
||||
search_mode = app_config.get("SEARCH_MODE", "universal", user_id=db_user_id)
|
||||
default_release_source = app_config.get(
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
default_release_source_audiobook = app_config.get(
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
"",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
configured_metadata_provider = normalize_optional_text(
|
||||
app_config.get(
|
||||
"METADATA_PROVIDER",
|
||||
"",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
)
|
||||
_configured_metadata_provider_audiobook = normalize_optional_text(
|
||||
app_config.get(
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
)
|
||||
metadata_ui_provider = (
|
||||
configured_metadata_provider or _configured_metadata_provider_audiobook
|
||||
)
|
||||
|
||||
config = {
|
||||
"calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""),
|
||||
"audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""),
|
||||
"search_page_title": app_config.get("SEARCH_PAGE_TITLE", "Shelfmark"),
|
||||
"debug": app_config.get("DEBUG", False),
|
||||
"build_version": BUILD_VERSION,
|
||||
"release_version": RELEASE_VERSION,
|
||||
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
|
||||
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
|
||||
"default_content_type": app_config.get(
|
||||
"DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id
|
||||
),
|
||||
"supported_formats": app_config.SUPPORTED_FORMATS,
|
||||
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
|
||||
"search_mode": search_mode,
|
||||
"metadata_sort_options": get_provider_sort_options(metadata_ui_provider),
|
||||
"metadata_search_fields": get_provider_search_fields(metadata_ui_provider),
|
||||
"default_release_source": default_release_source,
|
||||
"default_release_source_audiobook": default_release_source_audiobook,
|
||||
"show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True),
|
||||
"show_combined_selector": app_config.get(
|
||||
"SHOW_COMBINED_SELECTOR", True, user_id=db_user_id
|
||||
),
|
||||
"force_combined_search": app_config.get(
|
||||
"FORCE_COMBINED_SEARCH", False, user_id=db_user_id
|
||||
),
|
||||
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
|
||||
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
|
||||
"hardcover_auto_remove_on_download": app_config.get(
|
||||
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True
|
||||
),
|
||||
"download_to_browser_content_types": app_config.get(
|
||||
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
|
||||
[],
|
||||
user_id=db_user_id,
|
||||
),
|
||||
# The client must not give up before this budget does. `/api/releases`
|
||||
# answers a spent budget with a message naming the real cause (a protection
|
||||
# challenge nobody could solve); a browser that aborted first replaces it
|
||||
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
|
||||
# setting the user can raise with no visible effect. See issue #1285.
|
||||
"release_search_timeout": search_deadline.budget_seconds(),
|
||||
"settings_enabled": _is_config_dir_writable(),
|
||||
"onboarding_complete": _get_onboarding_complete(),
|
||||
# Default sort orders
|
||||
"default_sort": app_config.get(
|
||||
"AA_DEFAULT_SORT", ""
|
||||
), # For direct mode (Anna's Archive) — empty means use local downloads sort
|
||||
"metadata_default_sort": get_provider_default_sort(
|
||||
metadata_ui_provider
|
||||
), # For universal mode
|
||||
}
|
||||
return jsonify(config)
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Config error: {e}")
|
||||
return jsonify({"error": "Could not load the configuration"}), 500
|
||||
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
def api_health() -> Response | tuple[Response, int]:
|
||||
"""Health check endpoint for container orchestration.
|
||||
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with status "ok" and optional degraded features. Answers 503 with
|
||||
status "error" when the download coordinator is not running, since nothing is
|
||||
downloaded without it.
|
||||
|
||||
"""
|
||||
response: dict[str, object] = {"status": "ok"}
|
||||
|
||||
# Report degraded features
|
||||
if not backend.WEBSOCKET_AVAILABLE:
|
||||
response["degraded"] = {
|
||||
"websocket": "WebSocket unavailable - real-time updates disabled"
|
||||
}
|
||||
|
||||
coordinator_up = backend.coordinator_running()
|
||||
response["coordinator"] = "running" if coordinator_up else "stopped"
|
||||
if not coordinator_up:
|
||||
response["status"] = "error"
|
||||
return jsonify(response), 503
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
if debug_hooks is not None:
|
||||
|
||||
@app.route("/api/debug", methods=["GET"])
|
||||
@admin_required
|
||||
def debug() -> Response | tuple[Response, int]:
|
||||
"""Run `/app/genDebug.sh`, generate a debug zip, and return it.
|
||||
|
||||
The file is written to `/tmp/shelfmark-debug.zip` before being returned.
|
||||
"""
|
||||
try:
|
||||
logger.info("Debug endpoint called, stopping GUI and generating debug info...")
|
||||
debug_hooks.stop_gui()
|
||||
result = debug_hooks.run_script()
|
||||
if result.returncode != 0:
|
||||
_raise_runtime_error(f"Debug script failed: {result.stderr}")
|
||||
logger.info("Debug script executed: %s", result.stdout)
|
||||
debug_file_path = result.stdout.strip().split("\n")[-1]
|
||||
if not Path(debug_file_path).exists():
|
||||
logger.error("Debug zip file not found at: %s", debug_file_path)
|
||||
return jsonify({"error": "Failed to generate debug information"}), 500
|
||||
|
||||
logger.info("Sending debug file: %s", debug_file_path)
|
||||
return send_file(
|
||||
debug_file_path,
|
||||
mimetype="application/zip",
|
||||
download_name=Path(debug_file_path).name,
|
||||
as_attachment=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error_trace(
|
||||
f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}"
|
||||
)
|
||||
return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Debug endpoint error: {e}")
|
||||
return jsonify({"error": "Could not create the debug file"}), 500
|
||||
|
||||
@app.route("/api/restart", methods=["GET"])
|
||||
@admin_required
|
||||
def restart() -> Response | tuple[Response, int]:
|
||||
"""Restart the application."""
|
||||
os._exit(0)
|
||||
|
||||
# Catch-all route for React Router. Werkzeug tries static rules before dynamic ones, so it
|
||||
# does not have to be registered last.
|
||||
# This handles client-side routing by serving index.html for any unmatched routes
|
||||
@app.route("/<path:path>")
|
||||
def catch_all(path: str) -> Response | tuple[Response, int]:
|
||||
"""Serve the React app for any route not matched by API endpoints.
|
||||
|
||||
This allows React Router to handle client-side routing.
|
||||
Authentication is handled by the React app itself.
|
||||
"""
|
||||
# If the request is for an API endpoint or static file, let it 404
|
||||
if path.startswith(("api/", "assets/")):
|
||||
return jsonify({"error": "Resource not found"}), 404
|
||||
# Otherwise serve the React app
|
||||
return _serve_index_html()
|
||||
+107
-2
@@ -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
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -132,6 +139,31 @@ def _require_loaded_user(user: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return user
|
||||
|
||||
|
||||
def _book_data_with_failed_release(request_row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The request's book data with the release it was fulfilled with added to ``failed_releases``.
|
||||
|
||||
None when there is nothing to add: no readable release, or it is already listed.
|
||||
"""
|
||||
book_data = request_row.get("book_data")
|
||||
release_data = request_row.get("release_data")
|
||||
if not isinstance(book_data, dict) or not isinstance(release_data, dict):
|
||||
return None
|
||||
source = str(release_data.get("source") or "").strip()
|
||||
source_id = str(release_data.get("source_id") or "").strip()
|
||||
if not source or not source_id:
|
||||
return None
|
||||
tried = book_data.get("failed_releases")
|
||||
tried = list(tried) if isinstance(tried, list) else []
|
||||
if any(
|
||||
isinstance(entry, dict)
|
||||
and entry.get("source") == source
|
||||
and entry.get("source_id") == source_id
|
||||
for entry in tried
|
||||
):
|
||||
return None
|
||||
return {**book_data, "failed_releases": [*tried, {"source": source, "source_id": source_id}]}
|
||||
|
||||
|
||||
def get_users_db_path(config_dir: str | None = None) -> str:
|
||||
"""Return the configured users database path."""
|
||||
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
|
||||
@@ -919,11 +951,22 @@ class UserDB:
|
||||
request_id: int,
|
||||
*,
|
||||
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."""
|
||||
"""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. 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()
|
||||
@@ -951,6 +994,16 @@ class UserDB:
|
||||
):
|
||||
return None
|
||||
|
||||
failed_book_data = (
|
||||
_book_data_with_failed_release(current_request)
|
||||
if remember_failed_release
|
||||
else None
|
||||
)
|
||||
book_data_json = (
|
||||
None
|
||||
if failed_book_data is None
|
||||
else self._serialize_json(failed_book_data, "book_data")
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
@@ -958,12 +1011,14 @@ class UserDB:
|
||||
delivery_state = 'none',
|
||||
delivery_updated_at = NULL,
|
||||
release_data = NULL,
|
||||
book_data = COALESCE(?, book_data),
|
||||
last_failure_reason = ?,
|
||||
admin_note = COALESCE(?, admin_note),
|
||||
reviewed_by = NULL,
|
||||
reviewed_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(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 = ?",
|
||||
@@ -974,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,
|
||||
@@ -1011,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,),
|
||||
|
||||
@@ -662,13 +662,15 @@ class AllDebridClient(DownloadClient):
|
||||
buf = download_url(
|
||||
direct_link,
|
||||
referer="https://alldebrid.com/",
|
||||
spool_to_disk=True,
|
||||
)
|
||||
if not buf:
|
||||
msg = f"Failed to download from {safe_url(direct_link)}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
with dest.open("wb") as fh:
|
||||
fh.write(buf.getvalue())
|
||||
with buf, dest.open("wb") as fh:
|
||||
buf.seek(0)
|
||||
shutil.copyfileobj(buf, fh)
|
||||
|
||||
with state.lock:
|
||||
state.progress = 50.0 + (idx + 1) / total * 50.0
|
||||
|
||||
@@ -15,6 +15,7 @@ from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_int, normalize_optional_text
|
||||
from shelfmark.core.utils import AUDIOBOOK_FORMATS, DEFAULT_EBOOK_FORMATS, is_audiobook
|
||||
from shelfmark.download import torrent_ledger
|
||||
from shelfmark.download.activity import release_activity_grace, request_activity_grace
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
@@ -1149,6 +1150,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
|
||||
@@ -1194,6 +1198,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
return result
|
||||
|
||||
# Existing but still downloading - join the progress polling
|
||||
if request.protocol == "torrent":
|
||||
torrent_ledger.claim(download_id, task.task_id)
|
||||
logger.info("Existing download in progress, joining poll loop")
|
||||
status_callback("downloading", "Resuming existing download")
|
||||
else:
|
||||
@@ -1237,6 +1243,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
logger.info(
|
||||
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
|
||||
)
|
||||
if request.protocol == "torrent":
|
||||
torrent_ledger.record(download_id, task.task_id)
|
||||
if getattr(client, "handoff_only", False) is True:
|
||||
if cancel_flag.is_set():
|
||||
# The file is published and unpublishing it would race a watcher
|
||||
|
||||
@@ -740,8 +740,11 @@ class DebridLinkClient(DownloadClient):
|
||||
str(file_info.get("downloadUrl", "")),
|
||||
referer="https://debrid-link.com/",
|
||||
cancel_flag=state.cancel_event,
|
||||
spool_to_disk=True,
|
||||
)
|
||||
if state.cancel_event.is_set():
|
||||
if buf is not None:
|
||||
buf.close()
|
||||
return
|
||||
if not buf:
|
||||
# The URL is signed and stays out of the message, which ends up
|
||||
@@ -749,7 +752,7 @@ class DebridLinkClient(DownloadClient):
|
||||
msg = f"Failed to download {rel_path} from Debrid-Link"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
with dest.open("wb") as fh:
|
||||
with buf, dest.open("wb") as fh:
|
||||
buf.seek(0)
|
||||
shutil.copyfileobj(buf, fh)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from shelfmark.download.network import get_ssl_verify
|
||||
try:
|
||||
import qbittorrentapi as _qbittorrentapi
|
||||
except ImportError:
|
||||
_qbittorrentapi = None
|
||||
_ImportedQBittorrentApiError = RuntimeError
|
||||
_ImportedQBittorrentLoginFailed = RuntimeError
|
||||
else:
|
||||
@@ -45,6 +46,7 @@ logger = setup_logger(__name__)
|
||||
_HASH_LENGTH_40 = 40
|
||||
_HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
|
||||
# How long add_download waits for magnet metadata before falling back to the info
|
||||
@@ -103,6 +105,35 @@ def _resolve_qbittorrent_exception_type(candidate: object) -> type[Exception]:
|
||||
|
||||
_QBittorrentApiError = _resolve_qbittorrent_exception_type(_ImportedQBittorrentApiError)
|
||||
_QBittorrentLoginFailed = _resolve_qbittorrent_exception_type(_ImportedQBittorrentLoginFailed)
|
||||
|
||||
|
||||
def _qbittorrent_auth_errors(module: object) -> tuple[type[Exception], ...]:
|
||||
"""The qbittorrent-api errors that mean qBittorrent refused Shelfmark's login.
|
||||
|
||||
Empty when the library is missing, so nothing is mistaken for a refused login.
|
||||
"""
|
||||
found = (
|
||||
getattr(module, name, None) for name in ("LoginFailed", "HTTP401Error", "HTTP403Error")
|
||||
)
|
||||
return tuple(
|
||||
error for error in found if isinstance(error, type) and issubclass(error, Exception)
|
||||
)
|
||||
|
||||
|
||||
_QBITTORRENT_AUTH_ERRORS = _qbittorrent_auth_errors(_qbittorrentapi)
|
||||
|
||||
|
||||
def _auth_failure_message(error: Exception | None = None, *, status: int | None = None) -> str:
|
||||
"""Name a refused login as one, so the failure is not held against the release.
|
||||
|
||||
qbittorrent-api says nothing for a wrong password and only "Forbidden" or "Unauthorized"
|
||||
for a bad session or API key, which reads like a problem with the torrent.
|
||||
"""
|
||||
code = status if status is not None else getattr(error, "http_status_code", None)
|
||||
detail = f"HTTP {code}" if code else "login rejected"
|
||||
return f"qBittorrent authentication failed ({detail})"
|
||||
|
||||
|
||||
_QBITTORRENT_CLIENT_ERRORS = (
|
||||
_QBittorrentLoginFailed,
|
||||
_QBittorrentApiError,
|
||||
@@ -302,6 +333,10 @@ class QBittorrentClient(DownloadClient):
|
||||
return [SimpleNamespace(**t) for t in torrents], None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == _HTTP_STATUS_UNAUTHORIZED:
|
||||
msg = _auth_failure_message(status=status)
|
||||
logger.warning(msg)
|
||||
return [], msg
|
||||
if status:
|
||||
logger.warning("qBittorrent API error (HTTP %s): %s", status, e)
|
||||
return [], f"qBittorrent API request failed (HTTP {status})"
|
||||
@@ -321,6 +356,10 @@ class QBittorrentClient(DownloadClient):
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except _QBITTORRENT_AUTH_ERRORS as e:
|
||||
msg = _auth_failure_message(e)
|
||||
logger.warning(msg)
|
||||
return [], msg
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Failed to get torrents info: %s", e)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
@@ -350,6 +389,15 @@ class QBittorrentClient(DownloadClient):
|
||||
params = {"category": category} if category else {}
|
||||
return self._request_torrent_info_records(params)
|
||||
|
||||
def list_torrents(self, category: str | None = None) -> list[SimpleNamespace] | None:
|
||||
"""Every torrent record in ``category`` (all when unset); None when qBittorrent fails.
|
||||
|
||||
An empty list means qBittorrent answered and holds nothing, which a caller pruning
|
||||
what it remembers has to tell apart from a read that failed.
|
||||
"""
|
||||
torrents, error = self._list_torrents_by_category(category)
|
||||
return None if error else torrents
|
||||
|
||||
def _remember_primary_hash(self, download_id: str, torrent: SimpleNamespace) -> None:
|
||||
"""Note the primary hash a listing scan found, so later lookups skip the scan."""
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
@@ -608,6 +656,10 @@ class QBittorrentClient(DownloadClient):
|
||||
expected_hash,
|
||||
_METADATA_WAIT_POLLS * _METADATA_WAIT_INTERVAL_SECONDS,
|
||||
)
|
||||
except _QBITTORRENT_AUTH_ERRORS as e:
|
||||
msg = _auth_failure_message(e)
|
||||
logger.warning(msg)
|
||||
raise RuntimeError(msg) from e
|
||||
except _QBITTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("qBittorrent add failed")
|
||||
raise
|
||||
|
||||
@@ -517,13 +517,15 @@ class RealDebridClient(DownloadClient):
|
||||
buf = download_url(
|
||||
direct_url,
|
||||
referer="https://real-debrid.com/",
|
||||
spool_to_disk=True,
|
||||
)
|
||||
if not buf:
|
||||
msg = f"Failed to download from {direct_url}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
with dest.open("wb") as fh:
|
||||
fh.write(buf.getvalue())
|
||||
with buf, dest.open("wb") as fh:
|
||||
buf.seek(0)
|
||||
shutil.copyfileobj(buf, fh)
|
||||
|
||||
with state.lock:
|
||||
state.progress = 50.0 + (idx + 1) / total * 50.0
|
||||
|
||||
@@ -815,7 +815,10 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
TextField(
|
||||
key="TRANSMISSION_URL",
|
||||
label="Transmission URL",
|
||||
description="URL of your Transmission instance (use https:// for TLS)",
|
||||
description=(
|
||||
"URL of your Transmission instance (use https:// for TLS). Without a port, "
|
||||
"https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091"
|
||||
),
|
||||
placeholder="http://transmission:9091",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
@@ -1120,6 +1123,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
max_value=STALL_TIMEOUT_MAX_MINUTES,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
NumberField(
|
||||
key="STALE_TORRENT_HOURS",
|
||||
label="Remove dead torrents after (hours)",
|
||||
description=(
|
||||
"Remove a torrent Shelfmark added when it is stalled or cannot fetch its "
|
||||
"metadata, has had no activity for this many hours, and no running download "
|
||||
"owns it. Its partial data goes with it. Only torrents Shelfmark itself added "
|
||||
"are ever considered, whatever category they are in; torrents still "
|
||||
"downloading, queued, or finished are never touched. 3 to 72 hours (a value "
|
||||
"outside that is held to it); 0 keeps them. Checked every 15 minutes, "
|
||||
"independent of the Hardcover sync; only qBittorrent is supported."
|
||||
),
|
||||
default=6,
|
||||
min_value=0,
|
||||
max_value=72,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
key="usenet_heading",
|
||||
|
||||
@@ -528,8 +528,11 @@ class TorBoxClient(DownloadClient):
|
||||
direct_url,
|
||||
referer="https://torbox.app/",
|
||||
cancel_flag=state.cancel_event,
|
||||
spool_to_disk=True,
|
||||
)
|
||||
if state.cancel_event.is_set():
|
||||
if buffer is not None:
|
||||
buffer.close()
|
||||
return
|
||||
if not buffer:
|
||||
_raise_runtime_error(
|
||||
@@ -538,7 +541,7 @@ class TorBoxClient(DownloadClient):
|
||||
|
||||
destination = state.target_dir / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("wb") as output:
|
||||
with buffer, destination.open("wb") as output:
|
||||
buffer.seek(0)
|
||||
shutil.copyfileobj(buffer, output)
|
||||
with state.lock:
|
||||
|
||||
@@ -382,7 +382,9 @@ def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
|
||||
if protocol not in ("http", "https"):
|
||||
protocol = "http"
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 9091
|
||||
# An omitted port is scheme-specific: HTTPS behind a reverse proxy is 443,
|
||||
# while HTTP (including the empty-URL default) stays on Transmission's RPC port.
|
||||
port = parsed.port if parsed.port is not None else (443 if protocol == "https" else 9091)
|
||||
path = parsed.path or "/transmission/rpc"
|
||||
|
||||
# Ensure path ends with /rpc
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Periodic housekeeping for downloads, independent of the Hardcover sync.
|
||||
|
||||
One in-process daemon thread (the same pattern as the Hardcover scheduler) runs
|
||||
two sweeps every ``INTERVAL_SECONDS``: the dead-torrent sweep and the Soulseek
|
||||
leftovers sweep. Each is a no-op when its feature is off or its client is not
|
||||
configured, so the thread costs nothing for installs that use neither.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.download_history_service import DownloadHistoryService
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
STARTUP_DELAY_SECONDS = 30 # Lets the download coordinator and startup reconcile run first.
|
||||
INTERVAL_SECONDS = 15 * 60
|
||||
|
||||
_thread: threading.Thread | None = None
|
||||
_thread_lock = threading.Lock()
|
||||
_ctx: dict[str, Any] = {}
|
||||
|
||||
|
||||
def run_once(
|
||||
user_db: UserDB | None = None, history_service: DownloadHistoryService | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Run both sweeps. One failing never skips the other.
|
||||
|
||||
Returns what was done, keyed by sweep; a sweep that did nothing is absent.
|
||||
A sweep that raised is reported under ``errors``.
|
||||
"""
|
||||
summary: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
from shelfmark.download import stale_torrents
|
||||
|
||||
removed = stale_torrents.run_for_shelfmark().removed
|
||||
if removed:
|
||||
summary["torrents_removed"] = list(removed)
|
||||
except Exception:
|
||||
logger.exception("Housekeeping: dead-torrent sweep failed")
|
||||
errors.append("stale_torrents")
|
||||
|
||||
try:
|
||||
if user_db is not None and history_service is not None:
|
||||
from shelfmark.release_sources.slskd import leftovers
|
||||
|
||||
report = leftovers.run_with_backend(history_service=history_service, user_db=user_db)
|
||||
slskd = {
|
||||
name: list(items)
|
||||
for name, items in (
|
||||
("adopted", report.adopted),
|
||||
("removed", report.removed),
|
||||
("reattached", report.reattached),
|
||||
("cancelled", report.cancelled),
|
||||
)
|
||||
if items
|
||||
}
|
||||
if slskd:
|
||||
summary["slskd"] = slskd
|
||||
except Exception:
|
||||
logger.exception("Housekeeping: slskd leftovers sweep failed")
|
||||
errors.append("slskd_leftovers")
|
||||
|
||||
if errors:
|
||||
summary["errors"] = errors
|
||||
return summary
|
||||
|
||||
|
||||
def _loop() -> None:
|
||||
"""Run a housekeeping cycle every interval for the life of the process.
|
||||
|
||||
A failed cycle is logged and the loop carries on.
|
||||
"""
|
||||
logger.info("Housekeeping thread started")
|
||||
time.sleep(STARTUP_DELAY_SECONDS)
|
||||
while True:
|
||||
try:
|
||||
summary = run_once(_ctx.get("user_db"), _ctx.get("history_service"))
|
||||
if summary:
|
||||
logger.info("Housekeeping: %s", summary)
|
||||
except Exception:
|
||||
logger.exception("Housekeeping: cycle failed")
|
||||
time.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start(user_db: UserDB, history_service: DownloadHistoryService) -> None:
|
||||
"""Start the housekeeping thread (idempotent)."""
|
||||
global _thread
|
||||
with _thread_lock:
|
||||
_ctx["user_db"] = user_db
|
||||
_ctx["history_service"] = history_service
|
||||
if _thread is not None and _thread.is_alive():
|
||||
logger.debug("Housekeeping already running")
|
||||
return
|
||||
_thread = threading.Thread(target=_loop, daemon=True, name="DownloadHousekeeping")
|
||||
_thread.start()
|
||||
+182
-125
@@ -4,7 +4,8 @@ import random
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from typing import TYPE_CHECKING, Literal, NoReturn, overload
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
@@ -873,6 +874,7 @@ def html_get_page(
|
||||
)
|
||||
|
||||
|
||||
@overload
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str = "",
|
||||
@@ -881,8 +883,51 @@ def download_url(
|
||||
_selector: network.AAMirrorSelector | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
referer: str | None = None,
|
||||
) -> BytesIO | None:
|
||||
"""Download content from URL with automatic retry and resume support."""
|
||||
spool_to_disk: Literal[False] = False,
|
||||
) -> BytesIO | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str = "",
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
_selector: network.AAMirrorSelector | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
referer: str | None = None,
|
||||
*,
|
||||
spool_to_disk: Literal[True],
|
||||
) -> SpooledTemporaryFile[bytes] | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str,
|
||||
progress_callback: Callable[[float], None] | None,
|
||||
cancel_flag: Event | None,
|
||||
_selector: network.AAMirrorSelector | None,
|
||||
status_callback: Callable[[str, str | None], None] | None,
|
||||
referer: str | None,
|
||||
spool_to_disk: Literal[True],
|
||||
) -> SpooledTemporaryFile[bytes] | None: ...
|
||||
|
||||
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str = "",
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
_selector: network.AAMirrorSelector | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
referer: str | None = None,
|
||||
spool_to_disk: bool = False,
|
||||
) -> BytesIO | SpooledTemporaryFile[bytes] | None:
|
||||
"""Download content from URL with automatic retry and resume support.
|
||||
|
||||
Returns a ``BytesIO``, or a ``SpooledTemporaryFile`` when ``spool_to_disk`` is set.
|
||||
"""
|
||||
selector = _selector or network.AAMirrorSelector()
|
||||
current_url = selector.rewrite(link)
|
||||
try:
|
||||
@@ -904,134 +949,146 @@ def download_url(
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return None
|
||||
|
||||
buffer = BytesIO()
|
||||
buffer = (
|
||||
SpooledTemporaryFile(max_size=8 * 1024 * 1024, mode="w+b") # noqa: SIM115 - caller owns successful downloads
|
||||
if spool_to_disk
|
||||
else BytesIO()
|
||||
)
|
||||
bytes_downloaded = 0
|
||||
returned = False
|
||||
|
||||
try:
|
||||
if attempt > 0 and status_callback:
|
||||
status_callback(
|
||||
"resolving",
|
||||
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
|
||||
)
|
||||
try:
|
||||
if attempt > 0 and status_callback:
|
||||
status_callback(
|
||||
"resolving",
|
||||
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
|
||||
)
|
||||
|
||||
logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(
|
||||
current_url,
|
||||
stream=True,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
verify=get_ssl_verify(current_url),
|
||||
hooks=network.REDIRECT_GUARD_HOOKS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if status_callback:
|
||||
status_callback("downloading", "")
|
||||
|
||||
total_size = total_size or float(response.headers.get("content-length", 0))
|
||||
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
buffer.write(chunk)
|
||||
bytes_downloaded += len(chunk)
|
||||
pbar.update(len(chunk))
|
||||
if progress_callback and total_size > 0:
|
||||
progress_callback(bytes_downloaded * 100.0 / total_size)
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
pbar.close()
|
||||
return None
|
||||
pbar.close()
|
||||
|
||||
# Validate - check we didn't get HTML instead of file
|
||||
if (
|
||||
total_size > 0
|
||||
and bytes_downloaded < total_size * 0.9
|
||||
and response.headers.get("content-type", "").startswith("text/html")
|
||||
):
|
||||
logger.warning("Received HTML instead of file")
|
||||
return None
|
||||
|
||||
logger.debug("Download completed: %s bytes", bytes_downloaded)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
status = _get_status_code(e)
|
||||
retryable = _is_retryable_error(e)
|
||||
|
||||
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
|
||||
if (
|
||||
status == _HTTP_STATUS_FORBIDDEN
|
||||
and _is_cf_bypass_enabled()
|
||||
and not zlib_cookie_refresh_attempted
|
||||
):
|
||||
parsed = urlparse(current_url)
|
||||
if _is_configured_zlib_host(parsed.hostname) and referer:
|
||||
zlib_cookie_refresh_attempted = True
|
||||
logger.info("Z-Library 403 - refreshing cookies via referer")
|
||||
try:
|
||||
get_bypassed_page(referer, selector, cancel_flag)
|
||||
time.sleep(0.5)
|
||||
# Retry with fresh cookies (don't increment attempt)
|
||||
continue
|
||||
except _BYPASSER_ERRORS as cookie_err:
|
||||
logger.warning(
|
||||
"Z-Library cookie refresh failed: %s",
|
||||
type(cookie_err).__name__,
|
||||
)
|
||||
|
||||
# Non-retryable errors
|
||||
if status in _HTTP_STATUS_NON_RETRYABLE:
|
||||
logger.warning("Download failed (%s)", status)
|
||||
return None
|
||||
|
||||
# Rate limited - skip to next source immediately
|
||||
# (waiting doesn't help with concurrent downloads hitting the same server)
|
||||
if status == _HTTP_STATUS_RATE_LIMITED:
|
||||
network.note_rate_limited(current_url)
|
||||
logger.info("Rate limited (429) - trying next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server busy, trying next")
|
||||
return None
|
||||
|
||||
# Timeout - don't retry, server likely overloaded
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
logger.warning("Timeout - skipping to next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server timed out, trying next")
|
||||
return None
|
||||
|
||||
# Try to resume if we got some data
|
||||
if bytes_downloaded > 0 and retryable:
|
||||
resumed = _try_resume(
|
||||
logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(
|
||||
current_url,
|
||||
buffer,
|
||||
bytes_downloaded,
|
||||
total_size,
|
||||
progress_callback,
|
||||
cancel_flag,
|
||||
headers,
|
||||
stream=True,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
verify=get_ssl_verify(current_url),
|
||||
hooks=network.REDIRECT_GUARD_HOOKS,
|
||||
)
|
||||
if resumed:
|
||||
return resumed
|
||||
response.raise_for_status()
|
||||
|
||||
# Try mirror/DNS rotation if nothing downloaded yet
|
||||
if bytes_downloaded == 0 and retryable:
|
||||
new_url = _try_rotation(link, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
attempt += 1
|
||||
continue
|
||||
if status_callback:
|
||||
status_callback("downloading", "")
|
||||
|
||||
logger.warning("Download error: %s", type(e).__name__)
|
||||
if attempt < MAX_DOWNLOAD_RETRIES - 1:
|
||||
time.sleep(_backoff_delay(attempt + 1))
|
||||
attempt += 1
|
||||
else:
|
||||
return buffer
|
||||
total_size = total_size or float(response.headers.get("content-length", 0))
|
||||
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
buffer.write(chunk)
|
||||
bytes_downloaded += len(chunk)
|
||||
pbar.update(len(chunk))
|
||||
if progress_callback and total_size > 0:
|
||||
progress_callback(bytes_downloaded * 100.0 / total_size)
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
pbar.close()
|
||||
return None
|
||||
pbar.close()
|
||||
|
||||
# Validate - check we didn't get HTML instead of file
|
||||
if (
|
||||
total_size > 0
|
||||
and bytes_downloaded < total_size * 0.9
|
||||
and response.headers.get("content-type", "").startswith("text/html")
|
||||
):
|
||||
logger.warning("Received HTML instead of file")
|
||||
return None
|
||||
|
||||
logger.debug("Download completed: %s bytes", bytes_downloaded)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
status = _get_status_code(e)
|
||||
retryable = _is_retryable_error(e)
|
||||
|
||||
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
|
||||
if (
|
||||
status == _HTTP_STATUS_FORBIDDEN
|
||||
and _is_cf_bypass_enabled()
|
||||
and not zlib_cookie_refresh_attempted
|
||||
):
|
||||
parsed = urlparse(current_url)
|
||||
if _is_configured_zlib_host(parsed.hostname) and referer:
|
||||
zlib_cookie_refresh_attempted = True
|
||||
logger.info("Z-Library 403 - refreshing cookies via referer")
|
||||
try:
|
||||
get_bypassed_page(referer, selector, cancel_flag)
|
||||
time.sleep(0.5)
|
||||
# Retry with fresh cookies (don't increment attempt)
|
||||
continue
|
||||
except _BYPASSER_ERRORS as cookie_err:
|
||||
logger.warning(
|
||||
"Z-Library cookie refresh failed: %s",
|
||||
type(cookie_err).__name__,
|
||||
)
|
||||
|
||||
# Non-retryable errors
|
||||
if status in _HTTP_STATUS_NON_RETRYABLE:
|
||||
logger.warning("Download failed (%s)", status)
|
||||
return None
|
||||
|
||||
# Rate limited - skip to next source immediately
|
||||
# (waiting doesn't help with concurrent downloads hitting the same server)
|
||||
if status == _HTTP_STATUS_RATE_LIMITED:
|
||||
network.note_rate_limited(current_url)
|
||||
logger.info("Rate limited (429) - trying next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server busy, trying next")
|
||||
return None
|
||||
|
||||
# Timeout - don't retry, server likely overloaded
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
logger.warning("Timeout - skipping to next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server timed out, trying next")
|
||||
return None
|
||||
|
||||
# Try to resume if we got some data
|
||||
if bytes_downloaded > 0 and retryable:
|
||||
resumed = _try_resume(
|
||||
current_url,
|
||||
buffer,
|
||||
bytes_downloaded,
|
||||
total_size,
|
||||
progress_callback,
|
||||
cancel_flag,
|
||||
headers,
|
||||
)
|
||||
if resumed:
|
||||
returned = True
|
||||
return resumed
|
||||
|
||||
# Try mirror/DNS rotation if nothing downloaded yet
|
||||
if bytes_downloaded == 0 and retryable:
|
||||
new_url = _try_rotation(link, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
logger.warning("Download error: %s", type(e).__name__)
|
||||
if attempt < MAX_DOWNLOAD_RETRIES - 1:
|
||||
time.sleep(_backoff_delay(attempt + 1))
|
||||
attempt += 1
|
||||
else:
|
||||
returned = True
|
||||
return buffer
|
||||
|
||||
finally:
|
||||
if not returned:
|
||||
buffer.close()
|
||||
|
||||
logger.error("Download failed after %s attempts", MAX_DOWNLOAD_RETRIES)
|
||||
return None
|
||||
@@ -1057,13 +1114,13 @@ def _is_configured_zlib_host(hostname: str | None) -> bool:
|
||||
|
||||
def _try_resume(
|
||||
url: str,
|
||||
buffer: BytesIO,
|
||||
buffer: BytesIO | SpooledTemporaryFile[bytes],
|
||||
start_byte: int,
|
||||
total_size: float,
|
||||
progress_callback: Callable[[float], None] | None,
|
||||
cancel_flag: Event | None,
|
||||
base_headers: dict | None = None,
|
||||
) -> BytesIO | None:
|
||||
) -> BytesIO | SpooledTemporaryFile[bytes] | None:
|
||||
"""Try to resume an interrupted download."""
|
||||
for attempt in range(MAX_RESUME_ATTEMPTS):
|
||||
logger.info(
|
||||
|
||||
@@ -665,8 +665,14 @@ def retry_persisted_download(
|
||||
*,
|
||||
final_status: object,
|
||||
priority: int = -10,
|
||||
allow_request_linked: bool = False,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Retry a persisted download row after the in-memory task has been lost."""
|
||||
"""Retry a persisted download row after the in-memory task has been lost.
|
||||
|
||||
A request-linked download that failed is normally retried from its request, since the
|
||||
request decides what to fetch next. ``allow_request_linked`` queues it anyway, for work
|
||||
that is already done and only needs importing (a finished folder an interrupted run left).
|
||||
"""
|
||||
task = _restore_task_from_retry_payload(payload)
|
||||
if task is None:
|
||||
return False, "Download cannot be retried"
|
||||
@@ -684,15 +690,21 @@ def retry_persisted_download(
|
||||
if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context:
|
||||
return False, "Download cannot be retried"
|
||||
|
||||
if task.request_id is not None and normalized_status == "error" and not has_staged_retry_source:
|
||||
if (
|
||||
task.request_id is not None
|
||||
and normalized_status == "error"
|
||||
and not has_staged_retry_source
|
||||
and not allow_request_linked
|
||||
):
|
||||
return False, "Request-linked downloads must be retried from requests"
|
||||
|
||||
if (
|
||||
task.request_id is None
|
||||
(task.request_id is None or allow_request_linked)
|
||||
and normalized_status == "error"
|
||||
and not has_staged_retry_source
|
||||
and not has_fresh_retry_context
|
||||
):
|
||||
# The flag lifts the "retry it from the request" rule, never "nothing to restart from".
|
||||
return False, "Download cannot be retried"
|
||||
|
||||
task.priority = priority
|
||||
@@ -740,6 +752,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Remove torrents this instance added that are going nowhere and that nothing is watching.
|
||||
|
||||
A torrent outlives the task that added it: a restart, a stall timeout that left it in the client
|
||||
because it had some data. It then holds a download slot and a partial copy for ever. A torrent that
|
||||
is still downloading is none of this sweep's business; one that is stalled or stuck fetching its
|
||||
metadata, with no activity for hours, and that no running task owns, is dead.
|
||||
|
||||
Ownership never rests on a category: only a torrent whose hash is in the ledger of torrents this
|
||||
instance added (``torrent_ledger``) can be removed, and only when the task that added it is not
|
||||
running. A torrent from before the ledger, or one somebody else put in the same category, is never
|
||||
touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import torrent_ledger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
STALE_HOURS_SETTING = "STALE_TORRENT_HOURS"
|
||||
STALE_HOURS_DEFAULT = 6.0
|
||||
STALE_HOURS_MIN = 3.0
|
||||
STALE_HOURS_MAX = 72.0
|
||||
|
||||
# qBittorrent states of a torrent that is not moving: no peers gave it data, or it never got the
|
||||
# metadata. A queued torrent is waiting for a slot, not dead, and is left alone.
|
||||
_STUCK_STATES = frozenset({"stalledDL", "metaDL"})
|
||||
|
||||
# Task states that mean a task is still working with its torrent.
|
||||
_RUNNING_BUCKETS = frozenset({"queued", "resolving", "locating", "downloading"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaleReport:
|
||||
removed: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def stale_hours() -> float:
|
||||
"""The idle limit in hours: 0 turns the sweep off, anything else is held to 3 through 72."""
|
||||
raw = config.get(STALE_HOURS_SETTING, STALE_HOURS_DEFAULT)
|
||||
try:
|
||||
hours = float(raw) if isinstance(raw, (int, float, str)) else STALE_HOURS_DEFAULT
|
||||
except ValueError:
|
||||
return STALE_HOURS_DEFAULT
|
||||
if not math.isfinite(hours):
|
||||
# nan would pass every comparison below and turn the idle test off.
|
||||
return STALE_HOURS_DEFAULT
|
||||
if hours <= 0:
|
||||
return 0.0
|
||||
return min(max(hours, STALE_HOURS_MIN), STALE_HOURS_MAX)
|
||||
|
||||
|
||||
def _ledger_hash(torrent: Any, ledger: dict[str, str]) -> str | None:
|
||||
"""The ledger key this torrent answers to, matched on every identity the client exposes."""
|
||||
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
|
||||
|
||||
for known in ledger:
|
||||
if _torrent_matches_download_id(torrent, known):
|
||||
return known
|
||||
return None
|
||||
|
||||
|
||||
def find_stale(
|
||||
torrents: list[Any],
|
||||
*,
|
||||
ledger: dict[str, str],
|
||||
running_task_ids: set[str],
|
||||
idle_hours: float,
|
||||
now: float | None = None,
|
||||
) -> list[tuple[str, Any]]:
|
||||
"""``(ledger hash, torrent)`` for each torrent that is ours, stuck, idle and owned by nobody."""
|
||||
if idle_hours <= 0:
|
||||
return []
|
||||
moment = time.time() if now is None else now
|
||||
stale: list[tuple[str, Any]] = []
|
||||
for torrent in torrents:
|
||||
if float(getattr(torrent, "progress", 0.0) or 0.0) >= 1.0:
|
||||
continue
|
||||
if getattr(torrent, "state", "") not in _STUCK_STATES:
|
||||
continue
|
||||
last = float(getattr(torrent, "last_activity", 0) or 0)
|
||||
if last <= 0:
|
||||
last = float(getattr(torrent, "added_on", 0) or 0)
|
||||
if last <= 0 or moment - last < idle_hours * 3600:
|
||||
continue
|
||||
known = _ledger_hash(torrent, ledger)
|
||||
if known is None or ledger[known] in running_task_ids:
|
||||
continue
|
||||
stale.append((known, torrent))
|
||||
return stale
|
||||
|
||||
|
||||
def run(
|
||||
client: Any,
|
||||
*,
|
||||
running_task_ids: Callable[[], set[str]],
|
||||
now: float | None = None,
|
||||
) -> StaleReport:
|
||||
"""Remove the stale torrents (and their partial data) that this instance added."""
|
||||
report = StaleReport()
|
||||
idle = stale_hours()
|
||||
if idle <= 0:
|
||||
return report
|
||||
ledger = torrent_ledger.entries()
|
||||
if not ledger:
|
||||
return report
|
||||
torrents = client.list_torrents()
|
||||
if torrents is None:
|
||||
return report # qBittorrent did not answer: nothing is judged, nothing forgotten
|
||||
# A torrent qBittorrent no longer lists is forgotten, even when it lists none at all, so
|
||||
# the same torrent added later by someone else is never taken for one Shelfmark added.
|
||||
listed = {_ledger_hash(t, ledger) for t in torrents}
|
||||
torrent_ledger.forget(*(known for known in ledger if known not in listed))
|
||||
for known, torrent in find_stale(
|
||||
torrents,
|
||||
ledger=ledger,
|
||||
running_task_ids=running_task_ids(),
|
||||
idle_hours=idle,
|
||||
now=now,
|
||||
):
|
||||
if torrent_ledger.entries().get(known) != ledger[known]:
|
||||
# A task claimed the torrent after the ledger was read (a retry, say): it is live.
|
||||
continue
|
||||
if client.remove(torrent.hash, delete_files=True):
|
||||
torrent_ledger.forget(known)
|
||||
report.removed.append(str(getattr(torrent, "name", torrent.hash)))
|
||||
logger.info(
|
||||
"Removed a dead torrent nothing is watching: %s (%.0f%%, %s)",
|
||||
getattr(torrent, "name", torrent.hash),
|
||||
float(torrent.progress) * 100,
|
||||
torrent.state,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def run_for_shelfmark(*, now: float | None = None) -> StaleReport:
|
||||
"""``run`` against the configured qBittorrent."""
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.download.clients import get_client
|
||||
|
||||
report = StaleReport()
|
||||
if stale_hours() <= 0:
|
||||
return report
|
||||
client = get_client("torrent")
|
||||
if client is None or not hasattr(client, "list_torrents"):
|
||||
return report
|
||||
|
||||
def running_task_ids() -> set[str]:
|
||||
"""Ids of the tasks the queue is running now, whose torrents the sweep leaves alone."""
|
||||
ids: set[str] = set()
|
||||
for bucket, tasks in backend.queue_status().items():
|
||||
if bucket in _RUNNING_BUCKETS:
|
||||
ids.update(str(task_id) for task_id in tasks)
|
||||
return ids
|
||||
|
||||
return run(client, running_task_ids=running_task_ids, now=now)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""The info hashes of the torrents this instance added, and which task added them.
|
||||
|
||||
The stale-torrent sweep may only remove a torrent Shelfmark itself put in the client: a category
|
||||
says nothing about who else shares it. Every torrent an add returns a hash for is recorded here,
|
||||
with the task that added it, and the sweep touches nothing that is not in the ledger. A torrent
|
||||
added before the ledger existed is never in it, so it is never swept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import closing
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import CONFIG_DIR
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_SCHEMA = (
|
||||
"CREATE TABLE IF NOT EXISTS torrent_ledger ("
|
||||
"hash TEXT PRIMARY KEY, task_id TEXT NOT NULL, added_at REAL NOT NULL)"
|
||||
)
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
return CONFIG_DIR / "torrent_ledger.db"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(_path())
|
||||
conn.execute(_SCHEMA)
|
||||
return conn
|
||||
|
||||
|
||||
def record(torrent_hash: str, task_id: str) -> None:
|
||||
"""Note that ``task_id`` added the torrent. A ledger failure never fails the download."""
|
||||
if not torrent_hash:
|
||||
return
|
||||
try:
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"INSERT INTO torrent_ledger (hash, task_id, added_at) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(hash) DO UPDATE SET task_id = excluded.task_id",
|
||||
(torrent_hash.lower(), task_id, time.time()),
|
||||
)
|
||||
except (sqlite3.Error, OSError) as exc:
|
||||
logger.warning("Could not record torrent %s in the ledger: %s", torrent_hash, exc)
|
||||
|
||||
|
||||
def claim(torrent_hash: str, task_id: str) -> None:
|
||||
"""A task joined a torrent that is already in the ledger: it owns it now.
|
||||
|
||||
A torrent the ledger does not hold stays out of it, so joining something Shelfmark did
|
||||
not add never makes it sweepable.
|
||||
"""
|
||||
if not torrent_hash:
|
||||
return
|
||||
try:
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"UPDATE torrent_ledger SET task_id = ? WHERE hash = ?",
|
||||
(task_id, torrent_hash.lower()),
|
||||
)
|
||||
except (sqlite3.Error, OSError) as exc:
|
||||
logger.warning("Could not update torrent %s in the ledger: %s", torrent_hash, exc)
|
||||
|
||||
|
||||
def entries() -> dict[str, str]:
|
||||
"""Every recorded hash with the id of the task that last owned it."""
|
||||
try:
|
||||
with closing(_connect()) as conn:
|
||||
return {
|
||||
str(row[0]): str(row[1])
|
||||
for row in conn.execute("SELECT hash, task_id FROM torrent_ledger")
|
||||
}
|
||||
except (sqlite3.Error, OSError) as exc:
|
||||
logger.warning("Could not read the torrent ledger: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def forget(*torrent_hashes: str) -> None:
|
||||
"""Drop rows, once their torrent has been removed or is gone from the client."""
|
||||
hashes = [(h.lower(),) for h in torrent_hashes if h]
|
||||
if not hashes:
|
||||
return
|
||||
try:
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.executemany("DELETE FROM torrent_ledger WHERE hash = ?", hashes)
|
||||
except (sqlite3.Error, OSError) as exc:
|
||||
logger.warning("Could not update the torrent ledger: %s", exc)
|
||||
+127
-3140
File diff suppressed because it is too large.
Load diff
@@ -840,7 +840,12 @@ def _fetch_download_count_inline(book_id: str) -> int | None:
|
||||
return cached
|
||||
try:
|
||||
url = f"{network.get_aa_base_url()}/dyn/md5/inline_info/{book_id}"
|
||||
resp = _inline_info_session.get(url, timeout=5, headers={"Accept": "application/json"})
|
||||
resp = _inline_info_session.get(
|
||||
url,
|
||||
timeout=5,
|
||||
headers={"Accept": "application/json"},
|
||||
proxies=network.get_proxies(url),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
count = data.get("downloads_total")
|
||||
|
||||
@@ -399,6 +399,10 @@ class SlskdClient:
|
||||
data = self._json(response)
|
||||
return _flatten_transfers(data)
|
||||
|
||||
def list_all_downloads(self) -> list[dict[str, Any]]:
|
||||
"""Return every download transfer slskd tracks, for every user, flattened."""
|
||||
return _flatten_transfers(self._json(self._request("GET", "transfers/downloads")))
|
||||
|
||||
def get_transfer(self, username: str, transfer_id: str) -> dict[str, Any] | None:
|
||||
"""Return one transfer record, or None when slskd no longer tracks it."""
|
||||
try:
|
||||
@@ -427,6 +431,36 @@ class SlskdClient:
|
||||
raise
|
||||
return True
|
||||
|
||||
# ── private messages ──────────────────────────────────────────────────────
|
||||
|
||||
def list_conversations(self) -> list[dict[str, Any]]:
|
||||
"""Return slskd's private-message conversations (no message bodies)."""
|
||||
data = self._json(self._request("GET", "conversations"))
|
||||
return [c for c in data if isinstance(c, dict)] if isinstance(data, list) else []
|
||||
|
||||
def get_conversation(self, username: str) -> dict[str, Any] | None:
|
||||
"""Return one conversation with its messages, or None when slskd has none."""
|
||||
try:
|
||||
response = self._request(
|
||||
"GET",
|
||||
f"conversations/{quote(username, safe='')}",
|
||||
params={"includeMessages": "true"},
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND:
|
||||
return None
|
||||
raise
|
||||
data = self._json(response)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
def send_private_message(self, username: str, message: str) -> None:
|
||||
"""Send a private message; slskd takes the body as a bare JSON string."""
|
||||
self._request("POST", f"conversations/{quote(username, safe='')}", json=message)
|
||||
|
||||
def acknowledge_conversation(self, username: str) -> None:
|
||||
"""Mark every message in a conversation as read."""
|
||||
self._request("PUT", f"conversations/{quote(username, safe='')}")
|
||||
|
||||
|
||||
def _flatten_transfers(data: Any) -> list[dict[str, Any]]:
|
||||
"""Flatten slskd's ``{username, directories: [{directory, files: [...]}]}`` shape."""
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Answer anti-leech challenges that Soulseek uploaders send as private messages.
|
||||
|
||||
Some uploaders run bots that block your downloads until you type a token in a
|
||||
private message ("please type 'download' in this chat to be added to my
|
||||
whitelist"). The downloads then sit queued until someone answers.
|
||||
|
||||
The challenge patterns and the one-reply-per-cooldown idea come from SoulSync's
|
||||
``core/chat_autoprove.py`` (https://github.com/Nezreka/SoulSync, commit 445473b, MIT license,
|
||||
by Nezreka), reworked here for slskd's client and Shelfmark's download handler.
|
||||
|
||||
Deliberately conservative:
|
||||
- private messages only; only inbound, unacknowledged messages can trigger a reply
|
||||
- the token comes from the message itself and must match ``[A-Za-z0-9_-]{1,24}``
|
||||
- only the known phrasings below match; anything else is ignored
|
||||
- one reply per user per cooldown window (bots repeat their challenge)
|
||||
- the cooldown is recorded only after the reply was actually sent
|
||||
- every reply is logged at INFO so the operator can see it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources.slskd.api import SlskdError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.release_sources.slskd.api import SlskdClient
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
SCAN_INTERVAL_SECONDS = 60.0
|
||||
# A scan runs inside a download's poll loop, so it stops reading conversations after this long
|
||||
# and leaves the rest for the next pass.
|
||||
SCAN_BUDGET_SECONDS = 10.0
|
||||
COOLDOWN_SECONDS = 12 * 3600.0
|
||||
|
||||
_ERRORS = (SlskdError, requests.exceptions.RequestException)
|
||||
|
||||
_WORD = r"[A-Za-z0-9_-]{1,24}"
|
||||
_PATTERNS = (
|
||||
# please type "human" in this chat
|
||||
re.compile(rf"""type\s+["']?({_WORD})["']?\s+in\s+(?:this\s+)?chat""", re.IGNORECASE),
|
||||
# reply with "human" to be whitelisted
|
||||
re.compile(rf"""reply\s+(?:with\s+)?["']?({_WORD})["']?\s+to\s+(?:be|get)""", re.IGNORECASE),
|
||||
# say "human" in chat / say human to be added (not "I'd say thanks to everyone")
|
||||
re.compile(
|
||||
rf"""say\s+["']?({_WORD})["']?\s+(?:in\s+(?:this\s+|the\s+)?chat|to\s+(?:be|get))\b""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def extract_token(text: object) -> str | None:
|
||||
"""Return the token a challenge message demands, or None when it is not one."""
|
||||
body = str(text or "")
|
||||
for pattern in _PATTERNS:
|
||||
match = pattern.search(body)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _is_unread_inbound(message: Any) -> bool:
|
||||
"""Whether a chat message came from the peer and has not been acknowledged yet."""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if str(message.get("direction") or "").lower() != "in":
|
||||
return False
|
||||
return not message.get("acknowledged", message.get("isAcknowledged", False))
|
||||
|
||||
|
||||
def _has_unread(conversation: dict[str, Any]) -> bool:
|
||||
"""Whether slskd marks a conversation as having unread messages, by flag or by count."""
|
||||
count = conversation.get("unAcknowledgedMessageCount")
|
||||
return bool(conversation.get("hasUnAcknowledgedMessages")) or (
|
||||
isinstance(count, int) and count > 0
|
||||
)
|
||||
|
||||
|
||||
def _resume_order(
|
||||
conversations: list[dict[str, Any]], progress: dict[str, str] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The conversations, starting after the one the last pass read last and wrapping round."""
|
||||
last = (progress or {}).get("last")
|
||||
names = [str(c.get("username") or "") for c in conversations]
|
||||
if not last or last not in names:
|
||||
return list(conversations)
|
||||
start = names.index(last) + 1
|
||||
return list(conversations[start:]) + list(conversations[:start])
|
||||
|
||||
|
||||
def scan_and_respond(
|
||||
client: SlskdClient,
|
||||
state: dict[str, float],
|
||||
*,
|
||||
now: float,
|
||||
cooldown: float = COOLDOWN_SECONDS,
|
||||
clock: Callable[[], float] | None = None,
|
||||
budget: float | None = None,
|
||||
progress: dict[str, str] | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Run one pass and return the ``(username, token)`` replies sent.
|
||||
|
||||
``state`` maps a username to the time of the last reply and is owned by the
|
||||
caller. A failure on one conversation does not stop the others, but slskd timing
|
||||
out or refusing the connection ends the pass, and with ``clock`` and ``budget`` no
|
||||
further conversation is read once ``budget`` seconds have passed since ``now``.
|
||||
|
||||
``progress`` (owned by the caller) remembers the last conversation read, and the next pass
|
||||
starts after it and wraps round, so unread conversations that are not challenges cannot
|
||||
use up every pass's budget ahead of one that is.
|
||||
"""
|
||||
replies: list[tuple[str, str]] = []
|
||||
for conversation in _resume_order(client.list_conversations(), progress):
|
||||
username = str(conversation.get("username") or "")
|
||||
if not username or not _has_unread(conversation):
|
||||
continue
|
||||
last = state.get(username)
|
||||
if last is not None and now - last < cooldown:
|
||||
continue
|
||||
if clock is not None and budget is not None and clock() - now >= budget:
|
||||
logger.debug("slskd chat challenge: scan budget spent, the rest waits a pass")
|
||||
break
|
||||
|
||||
if progress is not None:
|
||||
progress["last"] = username
|
||||
try:
|
||||
detail = client.get_conversation(username) or {}
|
||||
token = None
|
||||
for message in detail.get("messages") or []:
|
||||
if _is_unread_inbound(message):
|
||||
token = extract_token(message.get("message"))
|
||||
if token:
|
||||
break
|
||||
if not token:
|
||||
continue
|
||||
client.send_private_message(username, token)
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
logger.warning("slskd chat challenge: slskd did not answer, scan stopped: %s", e)
|
||||
break
|
||||
except _ERRORS as e:
|
||||
logger.warning("slskd chat challenge: reply to %s failed: %s", username, e)
|
||||
continue
|
||||
|
||||
state[username] = now
|
||||
logger.info("slskd chat challenge: answered %r to %s", token, username)
|
||||
replies.append((username, token))
|
||||
try:
|
||||
client.acknowledge_conversation(username)
|
||||
except _ERRORS as e:
|
||||
logger.debug("slskd chat challenge: acknowledge for %s failed: %s", username, e)
|
||||
return replies
|
||||
|
||||
|
||||
class ChatAutoProver:
|
||||
"""Runs ``scan_and_respond`` at most once per interval, from whoever calls it.
|
||||
|
||||
The slskd handler already loops while a download is queued, so it calls
|
||||
``maybe_scan`` each iteration; no thread or scheduler of its own. Only one scan runs
|
||||
at a time: a caller that arrives while another thread is scanning returns at once.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
interval: float = SCAN_INTERVAL_SECONDS,
|
||||
cooldown: float = COOLDOWN_SECONDS,
|
||||
) -> None:
|
||||
self._interval = interval
|
||||
self._cooldown = cooldown
|
||||
self._lock = threading.Lock()
|
||||
self._last_scan: float | None = None
|
||||
self._scanning = False
|
||||
self._state: dict[str, float] = {}
|
||||
self._progress: dict[str, str] = {}
|
||||
|
||||
def maybe_scan(
|
||||
self,
|
||||
get_client: Callable[[], SlskdClient | None],
|
||||
clock: Callable[[], float],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Scan when the interval has passed; never raises, quiet when slskd is down."""
|
||||
now = clock()
|
||||
with self._lock:
|
||||
if self._scanning:
|
||||
return []
|
||||
if self._last_scan is not None and now - self._last_scan < self._interval:
|
||||
return []
|
||||
self._last_scan = now
|
||||
self._scanning = True
|
||||
try:
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return []
|
||||
return scan_and_respond(
|
||||
client,
|
||||
self._state,
|
||||
now=now,
|
||||
cooldown=self._cooldown,
|
||||
clock=clock,
|
||||
budget=SCAN_BUDGET_SECONDS,
|
||||
progress=self._progress,
|
||||
)
|
||||
except _ERRORS as e:
|
||||
logger.debug("slskd chat challenge: scan skipped: %s", e)
|
||||
return []
|
||||
except Exception: # a side feature must never stop the download polling it
|
||||
logger.exception("slskd chat challenge: unexpected scan error")
|
||||
return []
|
||||
finally:
|
||||
with self._lock:
|
||||
self._scanning = False
|
||||
@@ -47,6 +47,7 @@ from shelfmark.release_sources.slskd.api import (
|
||||
transfer_is_complete,
|
||||
transfer_succeeded,
|
||||
)
|
||||
from shelfmark.release_sources.slskd.chat_autoprove import ChatAutoProver
|
||||
from shelfmark.release_sources.slskd.source import (
|
||||
SOURCE_NAME,
|
||||
build_client_from_config,
|
||||
@@ -63,6 +64,9 @@ if TYPE_CHECKING:
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
POLL_INTERVAL = 2.0
|
||||
|
||||
# Shared by every download so concurrent polls still scan private messages once a minute.
|
||||
_CHAT_AUTOPROVER = ChatAutoProver()
|
||||
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||
# Polls with the transfer missing from slskd before giving up (~30s at POLL_INTERVAL).
|
||||
@@ -332,6 +336,19 @@ def _safe_name(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "_", value or "").strip("._") or "download"
|
||||
|
||||
|
||||
def folder_maps_back(task_id: str) -> bool:
|
||||
"""Whether a task's own folder name leads back to this task id and no other.
|
||||
|
||||
The folder is ``_safe_name(task_id)``, and the leftovers sweep reads the id back by turning
|
||||
the ``slskd_`` prefix into ``slskd:``. Search ids (``slskd:`` and hex) always map back. An id
|
||||
sent in a queued payload may not: ``slskd:ab+cd`` would land in ``slskd_ab_cd``, which the
|
||||
sweep would read as ``slskd:ab_cd``, find nothing recorded, and remove.
|
||||
"""
|
||||
if not task_id.startswith("slskd:"):
|
||||
return False
|
||||
return _safe_name(task_id) == "slskd_" + task_id[len("slskd:") :]
|
||||
|
||||
|
||||
def _clean_prefix(raw: object) -> str:
|
||||
"""The folder, relative to slskd's downloads folder, that holds Shelfmark's downloads."""
|
||||
text = str(raw or "").strip().replace("\\", "/").strip("/")
|
||||
@@ -391,6 +408,23 @@ def _discard_folder(folder: Path) -> None:
|
||||
logger.debug("Could not remove slskd folder %s: %s", folder, e)
|
||||
|
||||
|
||||
# Public names for the leftovers sweep, which reads the same settings and folder rules.
|
||||
def isolation_enabled() -> bool:
|
||||
return _bool_setting("SLSKD_ISOLATE_DOWNLOADS", True)
|
||||
|
||||
|
||||
def destination_prefix() -> str:
|
||||
return _clean_prefix(config.get("SLSKD_DESTINATION_PREFIX", DEFAULT_DESTINATION_PREFIX))
|
||||
|
||||
|
||||
def bool_setting(key: str, default: bool) -> bool:
|
||||
return _bool_setting(key, default)
|
||||
|
||||
|
||||
def folder_complete(folder: Path, expected_sizes: list[int]) -> bool:
|
||||
return _folder_complete(folder, expected_sizes)
|
||||
|
||||
|
||||
def _completed_remote_files(events: list[dict[str, Any]]) -> set[str]:
|
||||
"""Remote filenames slskd's stored events say finished downloading."""
|
||||
done: set[str] = set()
|
||||
@@ -469,6 +503,11 @@ class SlskdHandler(DownloadHandler):
|
||||
def _get_client(self) -> SlskdClient | None:
|
||||
return build_client_from_config()
|
||||
|
||||
def _answer_chat_challenges(self, client: SlskdClient) -> None:
|
||||
"""Reply to anti-leech bots that block downloads until they get a chat message."""
|
||||
if _bool_setting("SLSKD_CHAT_AUTOPROVE", False):
|
||||
_CHAT_AUTOPROVER.maybe_scan(lambda: client, time.monotonic)
|
||||
|
||||
# ── download ──────────────────────────────────────────────────────────────
|
||||
|
||||
def download(
|
||||
@@ -501,6 +540,11 @@ class SlskdHandler(DownloadHandler):
|
||||
return None
|
||||
|
||||
if self._isolate_enabled():
|
||||
if not folder_maps_back(task.task_id):
|
||||
# Its folder would read back as another id, and the sweep could remove it.
|
||||
status_callback("error", "This release's id cannot name its own download folder")
|
||||
logger.warning("slskd task id %r does not map back from its folder", task.task_id)
|
||||
return None
|
||||
outcome = self._download_isolated(
|
||||
task, spec, client, cancel_flag, progress_callback, status_callback
|
||||
)
|
||||
@@ -580,6 +624,10 @@ class SlskdHandler(DownloadHandler):
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> _TransferSnapshot | None:
|
||||
"""Follow a transfer until it finishes, fails, is cancelled or times out in the queue.
|
||||
|
||||
Answers chat challenges on the way when that is switched on.
|
||||
"""
|
||||
poll_interval = self._poll_interval()
|
||||
queue_timeout = self._queue_timeout_seconds()
|
||||
started = self._clock()
|
||||
@@ -589,6 +637,7 @@ class SlskdHandler(DownloadHandler):
|
||||
|
||||
try:
|
||||
while not cancel_flag.is_set():
|
||||
self._answer_chat_challenges(client)
|
||||
try:
|
||||
transfers = client.list_downloads(spec.username)
|
||||
except _SLSKD_ERRORS as e:
|
||||
@@ -929,6 +978,10 @@ class SlskdHandler(DownloadHandler):
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Follow an isolated download's files until they are all in its folder.
|
||||
|
||||
Returns None on success, else the reason it failed.
|
||||
"""
|
||||
poll_interval = self._poll_interval()
|
||||
queue_timeout = self._queue_timeout_seconds()
|
||||
started = self._clock()
|
||||
@@ -940,6 +993,7 @@ class SlskdHandler(DownloadHandler):
|
||||
|
||||
try:
|
||||
while not cancel_flag.is_set():
|
||||
self._answer_chat_challenges(client)
|
||||
now = self._clock()
|
||||
records: dict[str, dict[str, Any] | None] = {}
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Folders slskd left under Shelfmark's downloads folder: finish the whole ones, drop the dead ones.
|
||||
|
||||
Each download gets a folder named after its task id. A restart can land between "the last file
|
||||
arrived" and "the files were copied to the library", leaving a finished folder nothing will ever
|
||||
import, and a failed or abandoned download leaves a partial one. Both sit in slskd's folder
|
||||
(and are shared with other Soulseek users) until someone looks.
|
||||
|
||||
A whole folder whose download never completed is queued again through the ordinary retry; the
|
||||
handler sees the files are already there and goes straight to the import. It is queued once:
|
||||
if that import fails too, the folder stays, since the files may not be had again, until a
|
||||
person retries the download or its request is done with (delivered another way, or dropped).
|
||||
A partial folder, or the folder of a cancelled download, is removed once its download is old
|
||||
enough that nothing is going to resume it. Running downloads keep their folders, and nothing
|
||||
is removed while clean-up is off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_int
|
||||
from shelfmark.core.startup_reconcile import INTERRUPTED_MESSAGE
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.download_history_service import DownloadHistoryService
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
FINISH_SETTING = "SLSKD_FINISH_LEFTOVERS"
|
||||
CLEAN_DAYS_SETTING = "SLSKD_CLEAN_LEFTOVERS_DAYS"
|
||||
|
||||
# Folders of a request that already has its book, or that was given up on, go after this long.
|
||||
DELIVERED_GRACE = timedelta(days=1)
|
||||
# At most this many downloads are queued per run by each loop (finished folders, transfers picked
|
||||
# up again), counting only the ones that were queued, so a backlog does not flood the queue.
|
||||
MAX_ADOPTIONS_PER_RUN = 3
|
||||
# Left in a folder once it has been tried for import, queued or not. Hidden, so it never counts as
|
||||
# a file of the download. A folder is not tried again every run: it waits for a person to retry
|
||||
# the download, or for its request to be done with.
|
||||
ADOPTED_MARKER = ".shelfmark-adopted"
|
||||
_TASK_PREFIX = "slskd_"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeftoverReport:
|
||||
adopted: list[str] = field(default_factory=list)
|
||||
removed: list[str] = field(default_factory=list)
|
||||
kept: list[str] = field(default_factory=list)
|
||||
reattached: list[str] = field(default_factory=list) # task ids picked up again
|
||||
cancelled: list[str] = field(default_factory=list) # "<peer>: <file>" transfers dropped
|
||||
|
||||
|
||||
def _task_id_for_folder(name: str) -> str | None:
|
||||
"""The task id a folder is named after ('slskd_ab12' is 'slskd:ab12'), or None.
|
||||
|
||||
One-to-one because the handler only gives a task its own folder when the name maps back
|
||||
(``handler.folder_maps_back``); any other folder here was not made for a task.
|
||||
"""
|
||||
if not name.startswith(_TASK_PREFIX) or len(name) == len(_TASK_PREFIX):
|
||||
return None
|
||||
return "slskd:" + name[len(_TASK_PREFIX) :]
|
||||
|
||||
|
||||
def _parse_stamp(value: object) -> datetime | None:
|
||||
"""Parse a stored ISO timestamp, reading one without a zone as UTC.
|
||||
|
||||
None when it is missing or unreadable.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _expected_files(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The files a Soulseek download asked for, from its retry payload.
|
||||
|
||||
Empty when the payload is missing or malformed.
|
||||
"""
|
||||
payload = row.get("retry_payload")
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
return []
|
||||
context = payload.get("retry_source_context") if isinstance(payload, dict) else None
|
||||
files = context.get("files") if isinstance(context, dict) else None
|
||||
return [f for f in files if isinstance(f, dict)] if isinstance(files, list) else []
|
||||
|
||||
|
||||
def _folder_age(folder: Path, row: dict[str, Any] | None, now: datetime) -> timedelta:
|
||||
"""How old a download folder is.
|
||||
|
||||
Counted from the download's end, else its start, else the folder's own mtime.
|
||||
"""
|
||||
stamp = _parse_stamp((row or {}).get("terminal_at")) or _parse_stamp(
|
||||
(row or {}).get("queued_at")
|
||||
)
|
||||
if stamp is None:
|
||||
try:
|
||||
stamp = datetime.fromtimestamp(run_blocking_io(folder.stat).st_mtime, tz=UTC)
|
||||
except OSError:
|
||||
return timedelta(0)
|
||||
return now - stamp
|
||||
|
||||
|
||||
def _request_state(user_db: UserDB, row: dict[str, Any]) -> tuple[bool, bool]:
|
||||
"""(needs this download, already has its book or was given up on) for the row's request."""
|
||||
request_id = row.get("request_id")
|
||||
if not request_id:
|
||||
return True, False
|
||||
request = user_db.get_request(int(request_id))
|
||||
if not request:
|
||||
return False, True
|
||||
status = request.get("status")
|
||||
delivered = request.get("delivery_state") == "complete"
|
||||
still_waiting = status == "fulfilled" and not delivered
|
||||
return still_waiting, delivered or status in {"rejected", "cancelled"}
|
||||
|
||||
|
||||
def plan(
|
||||
folders: list[Path],
|
||||
*,
|
||||
history_service: DownloadHistoryService,
|
||||
user_db: UserDB,
|
||||
folder_complete: Any,
|
||||
keep_completed: bool,
|
||||
clean_after: timedelta | None,
|
||||
now: datetime | None = None,
|
||||
live_request_ids: set[int] | None = None,
|
||||
) -> tuple[list[tuple[Path, dict[str, Any]]], list[Path], list[Path]]:
|
||||
"""Sort folders into (to finish, to remove, to keep). Reads only, changes nothing.
|
||||
|
||||
What goes where, with ``clean_after`` the clean-up age (None while clean-up is off):
|
||||
|
||||
- not named after a task, or a running download: kept.
|
||||
- imported: removed a day on, unless ``keep_completed``. Clean-up has no say here.
|
||||
- every file asked for is there and its request is still open (or it was a direct
|
||||
download): queued for import once, when the request waits for it; kept otherwise.
|
||||
- the request has its book from elsewhere, or was dropped: removed a day on.
|
||||
- cancelled, partial, or nothing records it: removed at ``clean_after``.
|
||||
|
||||
``live_request_ids`` are the requests a running task is downloading for now; a folder of an
|
||||
older attempt for one of them is kept, not imported next to the new attempt.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
to_finish: list[tuple[Path, dict[str, Any]]] = []
|
||||
to_remove: list[Path] = []
|
||||
to_keep: list[Path] = []
|
||||
for folder in folders:
|
||||
task_id = _task_id_for_folder(folder.name)
|
||||
row = history_service.get_by_task_id(task_id) if task_id else None
|
||||
if task_id is None or (row is not None and row.get("task_id") != task_id):
|
||||
to_keep.append(folder)
|
||||
continue
|
||||
age = _folder_age(folder, row, now)
|
||||
status = str((row or {}).get("final_status") or "")
|
||||
|
||||
if row is None:
|
||||
# Nothing records this download. Old enough, and it is dead weight.
|
||||
(to_remove if clean_after and age >= clean_after else to_keep).append(folder)
|
||||
continue
|
||||
if status == "active":
|
||||
to_keep.append(folder) # a download is running or about to be reconciled
|
||||
continue
|
||||
if status == "complete":
|
||||
# Imported. The folder is only still here if cleanup failed, or it is kept shared.
|
||||
removable = not keep_completed and age >= DELIVERED_GRACE
|
||||
(to_remove if removable else to_keep).append(folder)
|
||||
continue
|
||||
|
||||
if status == "cancelled":
|
||||
# Someone cancelled it: never imported automatically. The folder goes with clean-up.
|
||||
(to_remove if clean_after is not None and age >= clean_after else to_keep).append(
|
||||
folder
|
||||
)
|
||||
continue
|
||||
|
||||
needs_it, done_with = _request_state(user_db, row)
|
||||
request_id = coerce_int(row.get("request_id"), 0)
|
||||
if needs_it and request_id and request_id in (live_request_ids or set()):
|
||||
to_keep.append(folder) # a newer attempt for this request is running
|
||||
continue
|
||||
sizes = [int(f.get("size") or 0) for f in _expected_files(row)]
|
||||
whole = folder_complete(folder, sizes)
|
||||
if whole and not done_with:
|
||||
# Every file asked for is here, and someone may still want it: a download a person
|
||||
# started, or a request that is still open. Never removed, whatever its age: the
|
||||
# files may not be had again, and a later attempt at the same release (the task id
|
||||
# is the release) lands in this folder and imports it without downloading. Queued
|
||||
# for import once; after that it waits for a person to retry the download.
|
||||
if needs_it and not run_blocking_io((folder / ADOPTED_MARKER).exists):
|
||||
to_finish.append((folder, row))
|
||||
else:
|
||||
to_keep.append(folder)
|
||||
elif done_with and clean_after is not None and age >= DELIVERED_GRACE:
|
||||
# The book is in the library, or the request was dropped: no need to wait the full
|
||||
# clean-up period, but only while clean-up is on (0 keeps every folder).
|
||||
to_remove.append(folder)
|
||||
elif not whole and clean_after is not None and age >= clean_after:
|
||||
to_remove.append(folder) # partial and old: nothing will resume it
|
||||
else:
|
||||
to_keep.append(folder)
|
||||
return to_finish, to_remove, to_keep
|
||||
|
||||
|
||||
_LIVE_TASK_STATES = frozenset({"queued", "resolving", "locating", "downloading"})
|
||||
|
||||
|
||||
def _is_unfinished(transfer: dict[str, Any]) -> bool:
|
||||
"""slskd transfer states are 'Queued, Remotely', 'InProgress', 'Completed, Errored'..."""
|
||||
return not str(transfer.get("state") or "").lower().startswith("completed")
|
||||
|
||||
|
||||
def _rows_by_file(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""The newest Soulseek history row that asked for each (peer, remote filename)."""
|
||||
index: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for row in rows: # newest first
|
||||
if row.get("source") != "slskd":
|
||||
continue
|
||||
payload = row.get("retry_payload")
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
context = payload.get("retry_source_context") if isinstance(payload, dict) else None
|
||||
if not isinstance(context, dict):
|
||||
continue
|
||||
peer = str(context.get("username") or "")
|
||||
for entry in context.get("files") or []:
|
||||
name = str(entry.get("filename") or "") if isinstance(entry, dict) else ""
|
||||
if peer and name:
|
||||
index.setdefault((peer, name), row)
|
||||
return index
|
||||
|
||||
|
||||
def _interrupted(row: dict[str, Any]) -> bool:
|
||||
"""Whether a download stopped without anyone giving up on it.
|
||||
|
||||
Either its row is still active with no task behind it, or startup marked it as cut short
|
||||
by a restart (an error with the "Interrupted" message). Any other ending was a real one.
|
||||
"""
|
||||
if row.get("final_status") == "active":
|
||||
return True
|
||||
return row.get("final_status") == "error" and row.get("status_message") == INTERRUPTED_MESSAGE
|
||||
|
||||
|
||||
def _queued_after_it_ended(transfer: dict[str, Any], row: dict[str, Any]) -> bool:
|
||||
"""Whether slskd queued this transfer after the Shelfmark download it matches had ended.
|
||||
|
||||
slskd will not queue a second transfer of a file from a peer while one is unfinished, and
|
||||
stamps each with ``requestedAt`` when it is queued. So a transfer asked for after the
|
||||
download ended is not one Shelfmark queued: another app or a person queued that file
|
||||
again. A running download has not ended, and a transfer with no readable stamp cannot be
|
||||
told apart, so both answer False.
|
||||
"""
|
||||
if row.get("final_status") == "active":
|
||||
return False
|
||||
ended = _parse_stamp(row.get("terminal_at"))
|
||||
requested = _parse_stamp(transfer.get("requestedAt"))
|
||||
return ended is not None and requested is not None and requested > ended
|
||||
|
||||
|
||||
def plan_transfers(
|
||||
transfers: list[dict[str, Any]],
|
||||
*,
|
||||
rows: list[dict[str, Any]],
|
||||
live_task_ids: set[str],
|
||||
live_request_ids: set[int],
|
||||
user_db: UserDB,
|
||||
) -> tuple[list[tuple[dict[str, Any], list[dict[str, Any]]]], list[dict[str, Any]]]:
|
||||
"""Split the unfinished transfers nothing is watching into (reattach, cancel).
|
||||
|
||||
``reattach`` holds one (history row, its transfers) pair per download to pick up again, so
|
||||
a download that cannot be picked up can have its transfers dropped instead.
|
||||
|
||||
Reads only. Only transfers a Shelfmark download asked for are considered, and only those
|
||||
slskd queued before that download ended; anything else on the slskd instance belongs to
|
||||
someone else and is left alone. A transfer is watched when a
|
||||
running task owns it. slskd keeps working on a
|
||||
transfer after the task that queued it is gone (a restart, a timeout), so these go on
|
||||
queueing at peers and writing into a folder nobody imports. One whose request still waits
|
||||
for that download is picked up again; the rest are dropped. A request that is pending is
|
||||
deciding what to fetch next, and may well choose this very release, so its transfers wait.
|
||||
"""
|
||||
index = _rows_by_file(rows)
|
||||
reattach: dict[str, tuple[dict[str, Any], list[dict[str, Any]]]] = {}
|
||||
cancel: list[dict[str, Any]] = []
|
||||
|
||||
def pick_up(row: dict[str, Any], transfer: dict[str, Any]) -> None:
|
||||
"""Note the transfer under its download, which is picked up once however many it has."""
|
||||
reattach.setdefault(str(row["task_id"]), (row, []))[1].append(transfer)
|
||||
|
||||
for transfer in transfers:
|
||||
if not _is_unfinished(transfer):
|
||||
continue
|
||||
row = index.get((str(transfer.get("username") or ""), str(transfer.get("filename") or "")))
|
||||
if row is not None and row.get("task_id") in live_task_ids:
|
||||
continue # a running task owns it
|
||||
if row is None:
|
||||
# Not one Shelfmark asked for: slskd may be shared with other apps or used by hand.
|
||||
continue
|
||||
if _queued_after_it_ended(transfer, row):
|
||||
continue # the same file queued again, by another app or by hand
|
||||
if row.get("final_status") == "complete":
|
||||
# That download finished; an unfinished transfer of the same file is someone else's.
|
||||
continue
|
||||
if row.get("final_status") == "cancelled":
|
||||
cancel.append(transfer) # someone cancelled this download; slskd should stop too
|
||||
continue
|
||||
request_id = row.get("request_id")
|
||||
if not request_id:
|
||||
if _interrupted(row):
|
||||
pick_up(row, transfer) # interrupted, not abandoned
|
||||
else:
|
||||
cancel.append(transfer)
|
||||
continue
|
||||
request = user_db.get_request(int(request_id))
|
||||
if not request:
|
||||
cancel.append(transfer)
|
||||
elif request.get("status") == "pending":
|
||||
continue
|
||||
elif int(request_id) in live_request_ids:
|
||||
cancel.append(transfer) # a newer attempt is running; this is an old one
|
||||
elif request.get("status") == "fulfilled" and request.get("delivery_state") != "complete":
|
||||
pick_up(row, transfer)
|
||||
else:
|
||||
cancel.append(transfer)
|
||||
return list(reattach.values()), cancel
|
||||
|
||||
|
||||
def _subfolders(base: Path) -> list[Path]:
|
||||
"""The folders directly inside base, sorted; run off the event loop by the caller."""
|
||||
return sorted(p for p in base.iterdir() if p.is_dir())
|
||||
|
||||
|
||||
def _is_inside(folder: Path, root: Path) -> bool:
|
||||
"""Whether folder is strictly inside root.
|
||||
|
||||
Both are resolved first, so a link cannot lead the sweep elsewhere.
|
||||
"""
|
||||
try:
|
||||
run_blocking_io(folder.resolve).relative_to(run_blocking_io(root.resolve))
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
return run_blocking_io(folder.resolve) != run_blocking_io(root.resolve)
|
||||
|
||||
|
||||
def run(
|
||||
*,
|
||||
history_service: DownloadHistoryService,
|
||||
user_db: UserDB,
|
||||
requeue: Any,
|
||||
live_tasks: Any = None,
|
||||
now: datetime | None = None,
|
||||
) -> LeftoverReport:
|
||||
"""Finish and clean up the folders under Shelfmark's slskd downloads folder.
|
||||
|
||||
Does nothing while the Soulseek source is switched off, or without a client, isolated
|
||||
downloads or a readable downloads folder.
|
||||
"""
|
||||
from shelfmark.release_sources.slskd import handler as slskd_handler
|
||||
|
||||
report = LeftoverReport()
|
||||
if not config.get("SLSKD_ENABLED", False):
|
||||
# Soulseek is switched off: an import queued now would be refused and still mark the
|
||||
# folder as tried, so it would not be imported when Soulseek is switched back on.
|
||||
return report
|
||||
client = slskd_handler.build_client_from_config()
|
||||
if client is None or not slskd_handler.isolation_enabled():
|
||||
return report
|
||||
root, error = slskd_handler.resolve_local_download_root(client)
|
||||
if root is None:
|
||||
logger.debug("slskd leftovers: %s", error)
|
||||
return report
|
||||
prefix = slskd_handler.destination_prefix()
|
||||
base = root.joinpath(*prefix.split("/"))
|
||||
try:
|
||||
folders = run_blocking_io(_subfolders, base)
|
||||
except OSError:
|
||||
return report
|
||||
|
||||
finish = slskd_handler.bool_setting(FINISH_SETTING, True)
|
||||
clean_days = _clean_days()
|
||||
clean_after = timedelta(days=clean_days) if clean_days > 0 else None
|
||||
keep_completed = slskd_handler.bool_setting("SLSKD_KEEP_COMPLETED", True)
|
||||
|
||||
live_now = live_tasks() if live_tasks is not None else {}
|
||||
to_finish, to_remove, to_keep = plan(
|
||||
folders,
|
||||
history_service=history_service,
|
||||
user_db=user_db,
|
||||
folder_complete=slskd_handler.folder_complete,
|
||||
keep_completed=keep_completed,
|
||||
clean_after=clean_after,
|
||||
now=now,
|
||||
live_request_ids={coerce_int(r, 0) for r in live_now.values() if r},
|
||||
)
|
||||
report.kept = [f.name for f in to_keep]
|
||||
|
||||
if finish:
|
||||
adopted_now = 0
|
||||
for folder, row in to_finish:
|
||||
if adopted_now >= MAX_ADOPTIONS_PER_RUN:
|
||||
report.kept.append(folder.name) # the next run takes it
|
||||
continue
|
||||
ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status"))
|
||||
if ok:
|
||||
adopted_now += 1
|
||||
report.adopted.append(folder.name)
|
||||
logger.info("slskd leftovers: importing the finished folder %s", folder.name)
|
||||
else:
|
||||
logger.warning("slskd leftovers: could not import %s: %s", folder.name, message)
|
||||
report.kept.append(folder.name)
|
||||
# Tried once either way: a folder that cannot be queued is not retried every run.
|
||||
try:
|
||||
run_blocking_io((folder / ADOPTED_MARKER).touch)
|
||||
except OSError as exc:
|
||||
logger.warning("slskd leftovers: could not mark %s: %s", folder.name, exc)
|
||||
else:
|
||||
report.kept.extend(f.name for f, _ in to_finish)
|
||||
|
||||
if finish and live_tasks is not None:
|
||||
_settle_transfers(
|
||||
client,
|
||||
history_service=history_service,
|
||||
user_db=user_db,
|
||||
requeue=requeue,
|
||||
live_tasks=live_tasks,
|
||||
report=report,
|
||||
)
|
||||
|
||||
# A folder whose download this run picked up again, or that a task is using now, is in use.
|
||||
busy = set(report.reattached)
|
||||
if live_tasks is not None:
|
||||
busy |= set(live_tasks())
|
||||
for folder in to_remove:
|
||||
if not _is_inside(folder, base):
|
||||
continue
|
||||
if _task_id_for_folder(folder.name) in busy:
|
||||
report.kept.append(folder.name)
|
||||
continue
|
||||
try:
|
||||
run_blocking_io(shutil.rmtree, folder)
|
||||
except OSError as exc:
|
||||
logger.warning("slskd leftovers: could not remove %s: %s", folder, exc)
|
||||
report.kept.append(folder.name)
|
||||
continue
|
||||
report.removed.append(folder.name)
|
||||
logger.info("slskd leftovers: removed %s", folder.name)
|
||||
return report
|
||||
|
||||
|
||||
def _settle_transfers(
|
||||
client: Any,
|
||||
*,
|
||||
history_service: DownloadHistoryService,
|
||||
user_db: UserDB,
|
||||
requeue: Any,
|
||||
live_tasks: Any,
|
||||
report: LeftoverReport,
|
||||
) -> None:
|
||||
"""Pick up or drop the unfinished slskd transfers no running task owns.
|
||||
|
||||
As with folders, only downloads that were queued count towards the per-run cap; the rest
|
||||
wait for the next run, their transfers untouched. One that cannot be queued has its
|
||||
transfers dropped, since nothing is ever going to watch them.
|
||||
"""
|
||||
try:
|
||||
transfers = client.list_all_downloads()
|
||||
except Exception as exc: # noqa: BLE001 - slskd being down is not this sweep's problem
|
||||
logger.debug("slskd leftovers: could not list transfers: %s", exc)
|
||||
return
|
||||
live = live_tasks() # {task id: request id or None} of the tasks running now
|
||||
reattach, cancel = plan_transfers(
|
||||
transfers,
|
||||
rows=history_service.list_recent(user_id=None, limit=1000),
|
||||
live_task_ids=set(live),
|
||||
live_request_ids={int(r) for r in live.values() if r},
|
||||
user_db=user_db,
|
||||
)
|
||||
picked_up = 0
|
||||
for row, owned in reattach:
|
||||
if picked_up >= MAX_ADOPTIONS_PER_RUN:
|
||||
break # the next run takes the rest
|
||||
ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status"))
|
||||
if ok:
|
||||
picked_up += 1
|
||||
report.reattached.append(str(row["task_id"]))
|
||||
logger.info("slskd leftovers: picked %s up again", row["task_id"])
|
||||
else:
|
||||
logger.warning(
|
||||
"slskd leftovers: could not pick up %s, dropping its transfers: %s",
|
||||
row["task_id"],
|
||||
message,
|
||||
)
|
||||
cancel.extend(owned)
|
||||
_drop_transfers(client, cancel, report)
|
||||
|
||||
|
||||
def _cancel_once(client: Any, transfer: dict[str, Any]) -> bool:
|
||||
"""Ask slskd to cancel one transfer and remove its record. False when slskd refused."""
|
||||
try:
|
||||
client.cancel_transfer(str(transfer.get("username")), str(transfer.get("id")), remove=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("slskd leftovers: could not cancel %s: %s", _label(transfer), exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _drop_transfers(client: Any, transfers: list[dict[str, Any]], report: LeftoverReport) -> None:
|
||||
"""Cancel the transfers and remove their records, noting each in the report when both land.
|
||||
|
||||
A live transfer needs a second pass once the cancel lands, so every transfer is cancelled
|
||||
first, then one wait covers them all before the second pass. One slskd refuses is left for
|
||||
the next run.
|
||||
"""
|
||||
pending = [t for t in transfers if _cancel_once(client, t)]
|
||||
if pending:
|
||||
time.sleep(2.0)
|
||||
for transfer in pending:
|
||||
if _cancel_once(client, transfer):
|
||||
report.cancelled.append(_label(transfer))
|
||||
logger.info("slskd leftovers: dropped an unwatched transfer, %s", _label(transfer))
|
||||
|
||||
|
||||
def _label(transfer: dict[str, Any]) -> str:
|
||||
"""'<peer>: <file>', as the report and the log name a transfer."""
|
||||
return f"{transfer.get('username')}: {transfer.get('filename')}"
|
||||
|
||||
|
||||
def _clean_days() -> int:
|
||||
return max(coerce_int(config.get(CLEAN_DAYS_SETTING, 0), 0), 0)
|
||||
|
||||
|
||||
def run_with_backend(
|
||||
*, history_service: DownloadHistoryService, user_db: UserDB, now: datetime | None = None
|
||||
) -> LeftoverReport:
|
||||
"""``run`` wired to the live download queue: requeue through the retry, read what is running."""
|
||||
from shelfmark.download import orchestrator as backend
|
||||
|
||||
def requeue(payload: object, final_status: object) -> tuple[bool, str | None]:
|
||||
return backend.retry_persisted_download(
|
||||
payload, final_status=final_status, allow_request_linked=True
|
||||
)
|
||||
|
||||
def live_tasks() -> dict[str, Any]:
|
||||
"""Task id to request id for every task the queue is still working on."""
|
||||
return {
|
||||
str(task_id): payload.get("request_id")
|
||||
for bucket, tasks in backend.queue_status().items()
|
||||
if bucket in _LIVE_TASK_STATES
|
||||
for task_id, payload in tasks.items()
|
||||
if isinstance(payload, dict)
|
||||
}
|
||||
|
||||
return run(
|
||||
history_service=history_service,
|
||||
user_db=user_db,
|
||||
requeue=requeue,
|
||||
live_tasks=live_tasks,
|
||||
now=now,
|
||||
)
|
||||
@@ -148,6 +148,44 @@ def slskd_config_settings() -> list[SettingsField]:
|
||||
),
|
||||
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SLSKD_FINISH_LEFTOVERS",
|
||||
label="Import finished folders an interruption left behind",
|
||||
default=True,
|
||||
description=(
|
||||
"A restart can land after the last file arrives and before it is copied to "
|
||||
"your library. Checked every 15 minutes, independent of the Hardcover sync, "
|
||||
"a download folder that holds every file asked for, whose download never "
|
||||
"completed and is still wanted, is imported once without downloading anything "
|
||||
"again (never one someone cancelled). If that import fails, the folder stays "
|
||||
"until you retry the download, or, with clean-up on, until it is a day old and "
|
||||
"its request has the book or was given up on. A transfer Shelfmark queued that "
|
||||
"no download is "
|
||||
"watching any more is picked up again if its request still needs it, a few per "
|
||||
"check, and cancelled otherwise, or when it cannot be picked up. Transfers "
|
||||
"queued by other apps or by hand in slskd are never touched, including the same "
|
||||
"file queued again after Shelfmark's download of it ended."
|
||||
),
|
||||
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="SLSKD_CLEAN_LEFTOVERS_DAYS",
|
||||
label="Remove leftover download folders after (days)",
|
||||
default=0,
|
||||
min_value=0,
|
||||
max_value=365,
|
||||
description=(
|
||||
"Remove the folder of a failed or cancelled download, or one nothing records, "
|
||||
"once the download is this old, or one day old when its request already has "
|
||||
"the book or was given up on. A folder that holds every file asked for is "
|
||||
"never removed while the download is still wanted (you started it yourself, "
|
||||
"or its request is still open): it is imported once, and after that it waits "
|
||||
"for you to retry the download. The folder of a running download is never "
|
||||
"removed. 0 keeps every leftover folder. Folders already imported follow "
|
||||
"Keep finished files in slskd, not this setting."
|
||||
),
|
||||
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="SLSKD_SEARCH_TIMEOUT",
|
||||
label="Search Wait (seconds)",
|
||||
@@ -181,6 +219,20 @@ def slskd_config_settings() -> list[SettingsField]:
|
||||
max_value=1440,
|
||||
show_when={"field": "SLSKD_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SLSKD_CHAT_AUTOPROVE",
|
||||
label="Answer anti-leech chat challenges",
|
||||
default=False,
|
||||
description=(
|
||||
"Some uploaders block downloads until you type a word in a private message "
|
||||
"(\"please type 'download' in this chat\"). Shelfmark replies with the word "
|
||||
"the message asks for, at most once per user every 12 hours, and logs each "
|
||||
"reply. It reads every unread private message in slskd, not only those from "
|
||||
"the peer of a running download, and marks a conversation it answered as read "
|
||||
"in slskd. Checked while a download is running."
|
||||
),
|
||||
show_when={"field": "SLSKD_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SLSKD_REMOVE_COMPLETED",
|
||||
label="Remove completed transfers from slskd",
|
||||
|
||||
+32
-17
@@ -109,6 +109,8 @@ import {
|
||||
import {
|
||||
applyDirectPolicyModeToButtonState,
|
||||
applyUniversalPolicyModeToButtonState,
|
||||
canRequestBookFromReleaseModal,
|
||||
shouldShowRequestsTab,
|
||||
} from './utils/requestPolicyUi';
|
||||
import { getSearchByPreference, setSearchByPreference } from './utils/searchByPreference';
|
||||
import { buildUrlSearchHash } from './utils/urlSearchHash';
|
||||
@@ -264,6 +266,7 @@ const AdminSettingsWarmupMount = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The application shell: search, results, the release modal, activity and settings. */
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
@@ -322,6 +325,7 @@ function App() {
|
||||
getSourceMode,
|
||||
requestsEnabled: requestsPolicyEnabled,
|
||||
allowNotes: allowRequestNotes,
|
||||
allowBookRequestWithDownload,
|
||||
refresh: refreshRequestPolicy,
|
||||
} = useRequestPolicy({
|
||||
enabled: isAuthenticated,
|
||||
@@ -455,20 +459,23 @@ function App() {
|
||||
};
|
||||
}, [activityStatus, currentStatus, dismissedDownloadTaskIds]);
|
||||
|
||||
const showRequestsTab = useMemo(() => {
|
||||
if (requestRoleIsAdmin) {
|
||||
return true;
|
||||
}
|
||||
if (!isAuthenticated || !requestsPolicyEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (!requestPolicy) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
requestPolicy.defaults.ebook === 'download' && requestPolicy.defaults.audiobook === 'download'
|
||||
);
|
||||
}, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]);
|
||||
const showRequestsTab = useMemo(
|
||||
() =>
|
||||
shouldShowRequestsTab({
|
||||
isAdmin: requestRoleIsAdmin,
|
||||
isAuthenticated,
|
||||
requestsEnabled: requestsPolicyEnabled,
|
||||
defaults: requestPolicy?.defaults ?? null,
|
||||
allowBookRequestWithDownload,
|
||||
}),
|
||||
[
|
||||
requestRoleIsAdmin,
|
||||
isAuthenticated,
|
||||
requestsPolicyEnabled,
|
||||
requestPolicy,
|
||||
allowBookRequestWithDownload,
|
||||
],
|
||||
);
|
||||
|
||||
// Search state and handlers
|
||||
const {
|
||||
@@ -2459,7 +2466,7 @@ function App() {
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
||||
debug={config?.debug || false}
|
||||
debug={(config?.debug || false) && authIsAdmin}
|
||||
logoUrl={logoUrl}
|
||||
showSearch={!isInitialState}
|
||||
searchInput={activeQueryValue}
|
||||
@@ -2671,7 +2678,15 @@ function App() {
|
||||
onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload}
|
||||
onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest}
|
||||
onRequestBook={
|
||||
isBrowseFulfilMode || !requestRoleIsAdmin ? undefined : handleReleaseBookRequest
|
||||
canRequestBookFromReleaseModal({
|
||||
isBrowseFulfilMode,
|
||||
isAdmin: requestRoleIsAdmin,
|
||||
requestsEnabled: requestsPolicyEnabled,
|
||||
allowBookRequestWithDownload,
|
||||
bookRequestMode: getSourceMode('*', activeReleaseContentType),
|
||||
})
|
||||
? handleReleaseBookRequest
|
||||
: undefined
|
||||
}
|
||||
getPolicyModeForSource={
|
||||
isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)
|
||||
@@ -2776,7 +2791,7 @@ function App() {
|
||||
pendingRequestCount={pendingRequestCount}
|
||||
showRequestsTab={showRequestsTab}
|
||||
isRequestsLoading={isActivitySnapshotLoading}
|
||||
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
|
||||
onRequestCancel={handleRequestCancel}
|
||||
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
|
||||
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
|
||||
onRequestReopen={requestRoleIsAdmin ? handleRequestReopen : undefined}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
import { isMetadataBook } from '../types';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { failedDownloadButtonState } from '../utils/failedDownloadButton';
|
||||
import { groupFilesByFormat, type FormatGroup } from '../utils/formatReview';
|
||||
import {
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
@@ -914,6 +915,18 @@ const ReleaseModalSession = ({
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const tabIndicatorStyle = useTabIndicator(tabRefs, activeTab, allTabs);
|
||||
const tabStripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The strip's scrollbar is hidden, so bring the selected tab fully into view. This sets
|
||||
// the strip's own scrollLeft: scrollIntoView would also scroll the modal vertically.
|
||||
const selectTab = (name: string, tab: HTMLButtonElement) => {
|
||||
setActiveTab(name);
|
||||
const strip = tabStripRef.current;
|
||||
strip?.scrollTo({
|
||||
left: tab.offsetLeft - (strip.clientWidth - tab.offsetWidth) / 2,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
// Get unique formats from current releases for filter dropdown
|
||||
// Only show formats that are in the supported list
|
||||
@@ -1183,9 +1196,7 @@ const ReleaseModalSession = ({
|
||||
if (mode === 'blocked' || mode === 'request_book') {
|
||||
return { text: 'Unavailable', state: 'blocked' };
|
||||
}
|
||||
return currentStatus.error[releaseId].retry_available === true
|
||||
? { text: 'Retry', state: 'download' }
|
||||
: { text: 'Failed', state: 'error' };
|
||||
return failedDownloadButtonState(currentStatus.error[releaseId]);
|
||||
}
|
||||
// Check completed
|
||||
if (currentStatus.complete && currentStatus.complete[releaseId]) {
|
||||
@@ -1814,9 +1825,16 @@ const ReleaseModalSession = ({
|
||||
</div>
|
||||
)}
|
||||
{!sourcesLoading && allTabs.length > 0 && (
|
||||
<div className="flex items-center px-5">
|
||||
{/* Tabs - scrollable on narrow screens */}
|
||||
<div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto">
|
||||
<div
|
||||
className={`flex items-center px-5 ${allTabs.length > 1 ? 'flex-wrap sm:flex-nowrap' : ''}`}
|
||||
>
|
||||
{/* Tabs - scrollable on narrow screens. With more than one source they get
|
||||
their own row on phones, because the controls would otherwise leave
|
||||
them about 110px. */}
|
||||
<div
|
||||
ref={tabStripRef}
|
||||
className={`scrollbar-hide min-w-0 flex-1 overflow-x-auto ${allTabs.length > 1 ? 'basis-full sm:basis-0' : ''}`}
|
||||
>
|
||||
<div className="relative flex gap-1">
|
||||
{/* Sliding indicator */}
|
||||
<div
|
||||
@@ -1833,7 +1851,7 @@ const ReleaseModalSession = ({
|
||||
ref={(el) => {
|
||||
tabRefs.current[tab.name] = el;
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.name)}
|
||||
onClick={(event) => selectTab(tab.name, event.currentTarget)}
|
||||
className={`border-b-2 border-transparent px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.name
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
|
||||
@@ -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}>
|
||||
{item.metaLine}
|
||||
</p>
|
||||
{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[] = [];
|
||||
|
||||
@@ -379,17 +327,6 @@ export const ActivitySidebar = ({
|
||||
};
|
||||
}, [downloadItems, visibleRequestItems]);
|
||||
|
||||
const hasTerminalDownloadItems = useMemo(
|
||||
() =>
|
||||
mergedDownloadItems.some(
|
||||
(item) =>
|
||||
item.visualStatus === 'complete' ||
|
||||
item.visualStatus === 'error' ||
|
||||
item.visualStatus === 'cancelled',
|
||||
),
|
||||
[mergedDownloadItems],
|
||||
);
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const combined = dedupeById([...mergedDownloadItems, ...mergedRequestItems]);
|
||||
return combined.toSorted((a, b) => b.timestamp - a.timestamp);
|
||||
@@ -412,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';
|
||||
@@ -422,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(() => {
|
||||
@@ -497,7 +446,7 @@ export const ActivitySidebar = ({
|
||||
);
|
||||
|
||||
const groupedVisibleItems = useMemo(() => {
|
||||
if (effectiveActiveTab === 'history') {
|
||||
if (effectiveActiveTab === 'history' || effectiveActiveTab === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -519,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);
|
||||
@@ -551,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"
|
||||
@@ -667,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')}
|
||||
@@ -714,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 */}
|
||||
@@ -797,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)]">
|
||||
@@ -855,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) => {
|
||||
@@ -958,8 +972,9 @@ export const ActivitySidebar = ({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{(effectiveActiveTab === 'downloads' || effectiveActiveTab === 'all') &&
|
||||
hasTerminalDownloadItems &&
|
||||
{(effectiveActiveTab === 'downloads' ||
|
||||
effectiveActiveTab === 'all' ||
|
||||
effectiveActiveTab === 'requests') &&
|
||||
clearCompletedTargets.length > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-center border-t p-3"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { ActivityDismissTarget } from './ActivitySidebar';
|
||||
import type { ActivityItem } from './activityTypes';
|
||||
|
||||
// A fulfilled request whose download is still under way is not finished.
|
||||
const ACTIVE_DELIVERY_STATES = new Set(['queued', 'resolving', 'locating', 'downloading']);
|
||||
|
||||
/**
|
||||
* Items the "Clear Completed" button dismisses: every finished download, plus the request
|
||||
* linked to it once that request is itself finished. The server only dismisses finished
|
||||
* requests and rejects the whole batch if one is still pending, which a restart can cause
|
||||
* by reopening the request of a download that already ended.
|
||||
*
|
||||
* Also every finished request card that has no download of its own: an approved request
|
||||
* nothing is still downloading for (auto-download closes a request whose book is already
|
||||
* in the library without downloading anything), or a cancelled one. Rejected requests
|
||||
* stay; they have their own view.
|
||||
*/
|
||||
export const getClearCompletedTargets = (
|
||||
visibleItems: ActivityItem[],
|
||||
@@ -21,6 +29,20 @@ export const getClearCompletedTargets = (
|
||||
const seen = new Set<string>();
|
||||
|
||||
visibleItems.forEach((item) => {
|
||||
if (item.kind === 'request' && typeof item.requestId === 'number') {
|
||||
const record = item.requestRecord;
|
||||
const finished =
|
||||
record?.status === 'cancelled' ||
|
||||
(record?.status === 'fulfilled' &&
|
||||
!ACTIVE_DELIVERY_STATES.has(record.delivery_state ?? ''));
|
||||
const requestKey = `request:${item.requestId}`;
|
||||
if (finished && !pendingRequestIds.has(item.requestId) && !seen.has(requestKey)) {
|
||||
seen.add(requestKey);
|
||||
targets.push({ itemType: 'request', itemKey: requestKey });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalDownload =
|
||||
item.kind === 'download' &&
|
||||
(item.visualStatus === 'complete' ||
|
||||
|
||||
@@ -31,6 +31,7 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
|
||||
'REQUEST_POLICY_RULES',
|
||||
'MAX_PENDING_REQUESTS_PER_USER',
|
||||
'REQUESTS_ALLOW_NOTES',
|
||||
'REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD',
|
||||
];
|
||||
|
||||
const requestPolicyHeading: HeadingFieldConfig = {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface PerUserSettings {
|
||||
REQUEST_POLICY_RULES?: Array<Record<string, unknown>>;
|
||||
MAX_PENDING_REQUESTS_PER_USER?: number;
|
||||
REQUESTS_ALLOW_NOTES?: boolean;
|
||||
REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserFormState {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useMountEffect } from '../../hooks/useMountEffect';
|
||||
import { getTooltipViewportNudge } from '../../utils/tooltipViewportNudge';
|
||||
|
||||
interface TooltipProps {
|
||||
content: ReactNode;
|
||||
@@ -112,44 +113,25 @@ export function Tooltip({
|
||||
return;
|
||||
}
|
||||
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const viewportPadding = 6;
|
||||
const nudge = getTooltipViewportNudge(
|
||||
tooltipRef.current.getBoundingClientRect(),
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
|
||||
if (tooltipRect.width + viewportPadding * 2 > window.innerWidth) {
|
||||
deltaX = viewportPadding - tooltipRect.left;
|
||||
} else {
|
||||
if (tooltipRect.left < viewportPadding) {
|
||||
deltaX = viewportPadding - tooltipRect.left;
|
||||
} else if (tooltipRect.right > window.innerWidth - viewportPadding) {
|
||||
deltaX = window.innerWidth - viewportPadding - tooltipRect.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (tooltipRect.height + viewportPadding * 2 > window.innerHeight) {
|
||||
deltaY = viewportPadding - tooltipRect.top;
|
||||
} else {
|
||||
if (tooltipRect.top < viewportPadding) {
|
||||
deltaY = viewportPadding - tooltipRect.top;
|
||||
} else if (tooltipRect.bottom > window.innerHeight - viewportPadding) {
|
||||
deltaY = window.innerHeight - viewportPadding - tooltipRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaX !== 0 || deltaY !== 0) {
|
||||
if (nudge.x !== 0 || nudge.y !== 0) {
|
||||
// Genuine measure-and-adjust: the tooltip must be laid out before we know
|
||||
// whether it overflows the viewport. The loop converges in one pass because
|
||||
// the corrected position yields deltaX/deltaY of 0 on the next run.
|
||||
// the corrected position measures as no nudge on the next run, which holds
|
||||
// only because the nudge ignores sub-pixel overflow.
|
||||
// oxlint-disable-next-line react/set-state-in-effect
|
||||
setCoords((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
top: current.top + deltaY,
|
||||
left: current.left + deltaX,
|
||||
top: current.top + nudge.y,
|
||||
left: current.left + nudge.x,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import type { StatusData, ButtonStateInfo } from '../types';
|
||||
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
|
||||
|
||||
interface UseDownloadTrackingReturn {
|
||||
bookToReleaseMap: Record<string, string[]>;
|
||||
@@ -12,6 +13,12 @@ interface UseDownloadTrackingReturn {
|
||||
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download button state for books and releases, from the queue status and this session's picks.
|
||||
*
|
||||
* In universal mode a book's button follows the releases downloaded for it, so the hook keeps
|
||||
* which releases belong to which book and which books finished during this session.
|
||||
*/
|
||||
export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrackingReturn {
|
||||
// Track mapping of metadata book IDs to release source IDs for universal mode
|
||||
const [bookToReleaseMap, setBookToReleaseMap] = useState<Record<string, string[]>>({});
|
||||
@@ -38,9 +45,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
|
||||
const getButtonState = useCallback(
|
||||
(bookId: string): ButtonStateInfo => {
|
||||
if (currentStatus.error && currentStatus.error[bookId]) {
|
||||
return currentStatus.error[bookId].retry_available === true
|
||||
? { text: 'Retry', state: 'download' }
|
||||
: { text: 'Failed', state: 'error' };
|
||||
return failedDownloadButtonState(currentStatus.error[bookId]);
|
||||
}
|
||||
if (currentStatus.complete && currentStatus.complete[bookId]) {
|
||||
return { text: 'Downloaded', state: 'complete' };
|
||||
@@ -67,7 +72,11 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
|
||||
[currentStatus],
|
||||
);
|
||||
|
||||
// Get button state for a metadata book in universal mode
|
||||
/**
|
||||
* The button for a metadata book in universal mode, from every release tracked for it. An
|
||||
* active download wins, then a finished one, then a failure that can be retried, then any
|
||||
* other failure.
|
||||
*/
|
||||
const getUniversalButtonState = useCallback(
|
||||
(bookId: string): ButtonStateInfo => {
|
||||
const releaseIds = bookToReleaseMap[bookId] || [];
|
||||
@@ -111,12 +120,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
|
||||
bestState = { text: 'Downloaded', state: 'complete' };
|
||||
foundActiveState = true;
|
||||
} else if (currentStatus.error && currentStatus.error[releaseId]) {
|
||||
if (bestState.state === 'download') {
|
||||
bestState =
|
||||
currentStatus.error[releaseId].retry_available === true
|
||||
? { text: 'Retry', state: 'download' }
|
||||
: { text: 'Failed', state: 'error' };
|
||||
}
|
||||
bestState = addFailedRelease(bestState, currentStatus.error[releaseId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -22,11 +22,15 @@ interface UseRequestPolicyReturn {
|
||||
isAdmin: boolean;
|
||||
requestsEnabled: boolean;
|
||||
allowNotes: boolean;
|
||||
allowBookRequestWithDownload: boolean;
|
||||
getDefaultMode: (contentType: string) => RequestPolicyMode;
|
||||
getSourceMode: (source: string, contentType: string) => RequestPolicyMode;
|
||||
refresh: (options?: { force?: boolean }) => Promise<RequestPolicyResponse | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the user's request policy, and resolve the mode for a source and content type.
|
||||
*/
|
||||
export function useRequestPolicy({
|
||||
enabled,
|
||||
isAdmin,
|
||||
@@ -102,6 +106,9 @@ export function useRequestPolicy({
|
||||
isAdmin: enabled && policy ? policy.is_admin : isAdmin,
|
||||
requestsEnabled: enabled ? (policy?.requests_enabled ?? false) : false,
|
||||
allowNotes: enabled ? (policy?.allow_notes ?? true) : true,
|
||||
allowBookRequestWithDownload: enabled
|
||||
? (policy?.allow_book_request_with_download ?? false)
|
||||
: false,
|
||||
getDefaultMode,
|
||||
getSourceMode,
|
||||
refresh,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -98,4 +98,32 @@ describe('getClearCompletedTargets', () => {
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears a finished request that never had a download', () => {
|
||||
const closed = makeRequestItem(30, 'fulfilled');
|
||||
closed.requestRecord = Object.assign({}, closed.requestRecord, {
|
||||
delivery_state: 'complete' as const,
|
||||
admin_note: '[auto] Already in the library.',
|
||||
});
|
||||
const cancelled = makeRequestItem(31, 'cancelled');
|
||||
|
||||
const targets = getClearCompletedTargets([closed, cancelled], [closed, cancelled]);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ itemType: 'request', itemKey: 'request:30' },
|
||||
{ itemType: 'request', itemKey: 'request:31' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves pending, rejected and still-downloading requests alone', () => {
|
||||
const pending = makeRequestItem(40, 'pending');
|
||||
const rejected = makeRequestItem(41, 'rejected');
|
||||
const downloading = makeRequestItem(42, 'fulfilled');
|
||||
downloading.requestRecord = Object.assign({}, downloading.requestRecord, {
|
||||
delivery_state: 'downloading' as const,
|
||||
});
|
||||
|
||||
const items = [pending, rejected, downloading];
|
||||
expect(getClearCompletedTargets(items, items)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { Book } from '../types';
|
||||
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
|
||||
|
||||
const failed = (fields: Partial<Book>): Book => ({
|
||||
id: 'rel-1',
|
||||
title: 'Dungeon Crawler Carl',
|
||||
author: 'Matt Dinniman',
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('failedDownloadButtonState', () => {
|
||||
it('offers a failed request download again so the request can be retried with it', () => {
|
||||
expect(failedDownloadButtonState(failed({ request_id: 7, retry_available: false }))).toEqual({
|
||||
text: 'Retry',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a retry when the queue can retry the download', () => {
|
||||
expect(failedDownloadButtonState(failed({ retry_available: true }))).toEqual({
|
||||
text: 'Retry',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a direct download with nothing to retry from as failed', () => {
|
||||
expect(failedDownloadButtonState(failed({ retry_available: false }))).toEqual({
|
||||
text: 'Failed',
|
||||
state: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFailedRelease', () => {
|
||||
const get = { text: 'Get', state: 'download' } as const;
|
||||
const retryable = failed({ request_id: 7, retry_available: false });
|
||||
const final = failed({ id: 'rel-2', retry_available: false });
|
||||
|
||||
it('keeps Retry when a release that cannot be retried is found after it', () => {
|
||||
expect(addFailedRelease(addFailedRelease(get, retryable), final)).toEqual({
|
||||
text: 'Retry',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('turns Failed into Retry when a retryable release is found after it', () => {
|
||||
expect(addFailedRelease(addFailedRelease(get, final), retryable)).toEqual({
|
||||
text: 'Retry',
|
||||
state: 'download',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the book failed when no failed release can be retried', () => {
|
||||
expect(addFailedRelease(get, final)).toEqual({ text: 'Failed', state: 'error' });
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,103 @@ import type { ButtonStateInfo } from '../types/index';
|
||||
import {
|
||||
applyDirectPolicyModeToButtonState,
|
||||
applyUniversalPolicyModeToButtonState,
|
||||
canRequestBookFromReleaseModal,
|
||||
shouldShowRequestsTab,
|
||||
} from '../utils/requestPolicyUi';
|
||||
|
||||
describe('canRequestBookFromReleaseModal', () => {
|
||||
const base = {
|
||||
isBrowseFulfilMode: false,
|
||||
isAdmin: false,
|
||||
requestsEnabled: true,
|
||||
allowBookRequestWithDownload: false,
|
||||
bookRequestMode: 'download' as const,
|
||||
};
|
||||
|
||||
it('is always on for an admin', () => {
|
||||
expect(canRequestBookFromReleaseModal({ ...base, isAdmin: true })).toBe(true);
|
||||
expect(canRequestBookFromReleaseModal({ ...base, isAdmin: true, requestsEnabled: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is off for a user unless the setting allows it', () => {
|
||||
expect(canRequestBookFromReleaseModal(base)).toBe(false);
|
||||
expect(canRequestBookFromReleaseModal({ ...base, allowBookRequestWithDownload: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is off for a user whose policy for this content type is not Download', () => {
|
||||
const allowed = { ...base, allowBookRequestWithDownload: true };
|
||||
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'request_release' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'request_book' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'blocked' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not depend on the policy mode for an admin', () => {
|
||||
expect(
|
||||
canRequestBookFromReleaseModal({ ...base, isAdmin: true, bookRequestMode: 'blocked' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('needs requests to be on for a user', () => {
|
||||
expect(
|
||||
canRequestBookFromReleaseModal({
|
||||
...base,
|
||||
requestsEnabled: false,
|
||||
allowBookRequestWithDownload: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is off while fulfilling a request, even for an admin', () => {
|
||||
expect(
|
||||
canRequestBookFromReleaseModal({ ...base, isAdmin: true, isBrowseFulfilMode: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowRequestsTab', () => {
|
||||
const allDownload = { ebook: 'download' as const, audiobook: 'download' as const };
|
||||
const base = {
|
||||
isAdmin: false,
|
||||
isAuthenticated: true,
|
||||
requestsEnabled: true,
|
||||
defaults: allDownload,
|
||||
allowBookRequestWithDownload: false,
|
||||
};
|
||||
|
||||
it('is hidden for a user who can only download', () => {
|
||||
expect(shouldShowRequestsTab(base)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows for a user who may request a book alongside downloads', () => {
|
||||
expect(shouldShowRequestsTab({ ...base, allowBookRequestWithDownload: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('shows when either default asks for requests', () => {
|
||||
expect(
|
||||
shouldShowRequestsTab({
|
||||
...base,
|
||||
defaults: { ebook: 'download', audiobook: 'request_book' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is hidden with requests off or no policy, and always shown to an admin', () => {
|
||||
const allowed = { ...base, allowBookRequestWithDownload: true };
|
||||
expect(shouldShowRequestsTab({ ...allowed, requestsEnabled: false })).toBe(false);
|
||||
expect(shouldShowRequestsTab({ ...allowed, defaults: null })).toBe(false);
|
||||
expect(shouldShowRequestsTab({ ...allowed, isAuthenticated: false })).toBe(false);
|
||||
expect(shouldShowRequestsTab({ ...base, isAdmin: true, requestsEnabled: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestPolicyUi', () => {
|
||||
const baseDownload: ButtonStateInfo = { text: 'Download', state: 'download' };
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getTooltipViewportNudge } from '../utils/tooltipViewportNudge';
|
||||
|
||||
const box = (left: number, top: number, width: number, height: number) => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
});
|
||||
|
||||
describe('getTooltipViewportNudge', () => {
|
||||
it('leaves a tooltip that fits where it is', () => {
|
||||
expect(getTooltipViewportNudge(box(100, 100, 80, 30), 1600, 900)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
// The two measurements Firefox took of the "Stop download" tooltip in #1646,
|
||||
// before and after the nudge, in a 1600x900 window.
|
||||
it('moves a tooltip that runs past the right edge back inside', () => {
|
||||
const rect = box(1536.2166748046875, 185, 67.566680908203125, 35.5);
|
||||
const nudge = getTooltipViewportNudge(rect, 1600, 900);
|
||||
expect(nudge.x).toBeCloseTo(1594 - rect.right, 10);
|
||||
expect(nudge.y).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a tooltip that measures a fraction of a pixel past the edge', () => {
|
||||
const rect = box(1526.433349609375, 185, 67.56666564941406, 35.5);
|
||||
expect(rect.right).toBeGreaterThan(1594);
|
||||
expect(getTooltipViewportNudge(rect, 1600, 900)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('still moves a tooltip that is half a pixel over', () => {
|
||||
expect(getTooltipViewportNudge(box(1514, 100, 80.5, 30), 1600, 900)).toEqual({
|
||||
x: -0.5,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a tooltip past the left or top edge back inside', () => {
|
||||
expect(getTooltipViewportNudge(box(-20, -10, 80, 30), 1600, 900)).toEqual({ x: 26, y: 16 });
|
||||
});
|
||||
|
||||
it('moves a tooltip past the bottom edge up', () => {
|
||||
expect(getTooltipViewportNudge(box(100, 880, 80, 30), 1600, 900)).toEqual({ x: 0, y: -16 });
|
||||
});
|
||||
|
||||
it('pins a tooltip wider than the viewport to the left edge', () => {
|
||||
expect(getTooltipViewportNudge(box(40, 100, 400, 30), 390, 900)).toEqual({ x: -34, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -217,6 +219,7 @@ export interface RequestPolicyResponse {
|
||||
requests_enabled: boolean;
|
||||
is_admin: boolean;
|
||||
allow_notes: boolean;
|
||||
allow_book_request_with_download?: boolean;
|
||||
defaults: RequestPolicyDefaults;
|
||||
rules: Array<Record<string, unknown>>;
|
||||
source_modes: RequestPolicySourceMode[];
|
||||
@@ -264,6 +267,7 @@ export interface RequestRecord {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
username?: string;
|
||||
reviewer_username?: string;
|
||||
}
|
||||
|
||||
export interface QueuedDownloadResult {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Book, ButtonStateInfo } from '../types';
|
||||
|
||||
const RETRY: ButtonStateInfo = { text: 'Retry', state: 'download' };
|
||||
const FAILED: ButtonStateInfo = { text: 'Failed', state: 'error' };
|
||||
|
||||
/**
|
||||
* The button for a release whose last download failed.
|
||||
*
|
||||
* When the queue cannot resume a failed download of a request, the request is reopened so a
|
||||
* release can be picked again, and the one that failed may be fine (its client was down or
|
||||
* refused the login). So it is offered again, like a download the queue can retry. Any other
|
||||
* failure with nothing to retry from stays marked as failed.
|
||||
*/
|
||||
export function failedDownloadButtonState(entry: Book): ButtonStateInfo {
|
||||
return entry.retry_available === true || (entry.request_id ?? 0) > 0
|
||||
? { ...RETRY }
|
||||
: { ...FAILED };
|
||||
}
|
||||
|
||||
/**
|
||||
* The button for a book with several tracked releases once another of them is found failed.
|
||||
*
|
||||
* A failure that can be retried wins over one that can't, in whichever order they are found, so
|
||||
* the book keeps Retry while any of its failed releases can be tried again.
|
||||
*/
|
||||
export function addFailedRelease(best: ButtonStateInfo, entry: Book): ButtonStateInfo {
|
||||
if (best.state === RETRY.state && best.text === RETRY.text) {
|
||||
return best;
|
||||
}
|
||||
const failed = failedDownloadButtonState(entry);
|
||||
return failed.text === RETRY.text || best.state === 'download' ? failed : best;
|
||||
}
|
||||
@@ -1,5 +1,54 @@
|
||||
import type { ButtonStateInfo, RequestPolicyMode } from '../types';
|
||||
|
||||
/**
|
||||
* Whether the release modal offers a book-level request ("Add to requests"). Admins always get
|
||||
* it. Anyone else gets it only when requests are on, the setting lets users who can download
|
||||
* also ask for a book without choosing a release, and their policy for this content type is
|
||||
* Download. The server turns a book-level request into a pending one only in Download mode, so
|
||||
* the action would be refused (Blocked) or mean something else (Request Release) otherwise.
|
||||
*/
|
||||
export const canRequestBookFromReleaseModal = ({
|
||||
isBrowseFulfilMode,
|
||||
isAdmin,
|
||||
requestsEnabled,
|
||||
allowBookRequestWithDownload,
|
||||
bookRequestMode,
|
||||
}: {
|
||||
isBrowseFulfilMode: boolean;
|
||||
isAdmin: boolean;
|
||||
requestsEnabled: boolean;
|
||||
allowBookRequestWithDownload: boolean;
|
||||
bookRequestMode: RequestPolicyMode;
|
||||
}): boolean => {
|
||||
if (isBrowseFulfilMode) return false;
|
||||
if (isAdmin) return true;
|
||||
return requestsEnabled && allowBookRequestWithDownload && bookRequestMode === 'download';
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the activity sidebar shows a Requests tab. Admins always see it. A user sees it when
|
||||
* requests are on and either default asks for requests, or when they may request a book
|
||||
* alongside downloads, since those requests then need a place to be followed and cancelled.
|
||||
*/
|
||||
export const shouldShowRequestsTab = ({
|
||||
isAdmin,
|
||||
isAuthenticated,
|
||||
requestsEnabled,
|
||||
defaults,
|
||||
allowBookRequestWithDownload,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
isAuthenticated: boolean;
|
||||
requestsEnabled: boolean;
|
||||
defaults: { ebook: RequestPolicyMode; audiobook: RequestPolicyMode } | null;
|
||||
allowBookRequestWithDownload: boolean;
|
||||
}): boolean => {
|
||||
if (isAdmin) return true;
|
||||
if (!isAuthenticated || !requestsEnabled || !defaults) return false;
|
||||
if (allowBookRequestWithDownload) return true;
|
||||
return !(defaults.ebook === 'download' && defaults.audiobook === 'download');
|
||||
};
|
||||
|
||||
export const applyDirectPolicyModeToButtonState = (
|
||||
baseState: ButtonStateInfo,
|
||||
mode: RequestPolicyMode,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
type MeasuredBox = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>;
|
||||
|
||||
interface ViewportNudge {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const VIEWPORT_PADDING = 6;
|
||||
|
||||
// Firefox measures a transformed box in single precision, so a tooltip already moved flush
|
||||
// to the edge can read 0.00001px past it. Moving it by that much changes nothing, the next
|
||||
// measurement shows the same overflow, and the tooltip's layout effect loops until React
|
||||
// gives up with error #185 and the page goes blank (#1646).
|
||||
const SUBPIXEL_TOLERANCE = 0.5;
|
||||
|
||||
const nudgeAxis = (start: number, end: number, size: number, viewport: number): number => {
|
||||
let delta = 0;
|
||||
if (size + VIEWPORT_PADDING * 2 > viewport || start < VIEWPORT_PADDING) {
|
||||
delta = VIEWPORT_PADDING - start;
|
||||
} else if (end > viewport - VIEWPORT_PADDING) {
|
||||
delta = viewport - VIEWPORT_PADDING - end;
|
||||
}
|
||||
return Math.abs(delta) < SUBPIXEL_TOLERANCE ? 0 : delta;
|
||||
};
|
||||
|
||||
/**
|
||||
* How far to move a tooltip so it stays inside the viewport, keeping a small gap
|
||||
* from each edge. A tooltip bigger than the viewport is pinned to the top/left edge.
|
||||
* Overflow under half a pixel counts as none.
|
||||
*/
|
||||
export const getTooltipViewportNudge = (
|
||||
rect: MeasuredBox,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
): ViewportNudge => ({
|
||||
x: nudgeAxis(rect.left, rect.right, rect.width, viewportWidth),
|
||||
y: nudgeAxis(rect.top, rect.bottom, rect.height, viewportHeight),
|
||||
});
|
||||
@@ -24,6 +24,12 @@ def _clean_display():
|
||||
ib.DISPLAY.update(before)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ffmpeg_on_path(monkeypatch):
|
||||
# The recorder checks for the binary first; these tests stub the process itself.
|
||||
monkeypatch.setattr(ib.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, returncode):
|
||||
self.returncode = returncode
|
||||
@@ -133,3 +139,23 @@ def test_summary_survives_a_missing_log():
|
||||
ib.DISPLAY["ffmpeg_error_log"] = None
|
||||
|
||||
assert "No FFmpeg error log" in ib._ffmpeg_error_summary()
|
||||
|
||||
|
||||
def test_debug_mode_without_ffmpeg_skips_the_recording_and_says_so(monkeypatch, tmp_path):
|
||||
"""Debug mode can be on with no ffmpeg in the image; that must not fail the bypass."""
|
||||
monkeypatch.setattr(ib, "RECORDING_DIR", tmp_path / "recording")
|
||||
monkeypatch.setattr(ib.shutil, "which", lambda _name: None)
|
||||
|
||||
def popen_must_not_run(*_args, **_kwargs):
|
||||
raise AssertionError("ffmpeg should not be started when it is not installed")
|
||||
|
||||
monkeypatch.setattr(ib.subprocess, "Popen", popen_must_not_run)
|
||||
|
||||
warnings: list[str] = []
|
||||
monkeypatch.setattr(ib.logger, "warning", lambda msg, *_args: warnings.append(msg))
|
||||
|
||||
ib._start_ffmpeg_recording(display=":99")
|
||||
|
||||
assert ib.DISPLAY["ffmpeg"] is None
|
||||
assert any("ffmpeg is not installed" in message for message in warnings)
|
||||
assert not (tmp_path / "recording").exists()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""The Debug mode ffmpeg install: what it runs, what it logs, and what it can't block."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "entrypoint.sh"
|
||||
BASH_PATH = shutil.which("bash") or "/bin/bash"
|
||||
|
||||
|
||||
def _stub(bin_dir: Path, name: str, body: str) -> None:
|
||||
path = bin_dir / name
|
||||
path.write_text(f"#!/bin/sh\n{body}\n")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _install_ffmpeg_source() -> str:
|
||||
text = ENTRYPOINT_PATH.read_text()
|
||||
start = text.index("install_ffmpeg() {")
|
||||
return text[start : text.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
def _run(
|
||||
tmp_path: Path, *, apt_body: str, ffmpeg_works: bool = True
|
||||
) -> tuple[subprocess.CompletedProcess[str], list[str]]:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
calls = tmp_path / "apt-calls.txt"
|
||||
_stub(
|
||||
bin_dir,
|
||||
"apt-get",
|
||||
f'echo "frontend=$DEBIAN_FRONTEND $*" >> "{calls}"\n{apt_body}',
|
||||
)
|
||||
_stub(bin_dir, "dpkg", "exit 0")
|
||||
_stub(bin_dir, "ffmpeg", "exit 0" if ffmpeg_works else "exit 1")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({"PATH": f"{bin_dir}:{env.get('PATH', '')}", "FFMPEG_INSTALL_TIMEOUT": "1"})
|
||||
result = subprocess.run(
|
||||
[BASH_PATH, "-c", f"{_install_ffmpeg_source()}\ninstall_ffmpeg"],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
return result, calls.read_text().splitlines() if calls.exists() else []
|
||||
|
||||
|
||||
def test_install_downloads_first_then_installs_without_the_network(tmp_path):
|
||||
result, calls = _run(tmp_path, apt_body="exit 0")
|
||||
|
||||
assert "ffmpeg installed" in result.stdout
|
||||
assert [call.split()[1] for call in calls] == ["update", "install", "install"]
|
||||
assert "--download-only" in calls[1]
|
||||
assert "--no-download" in calls[2]
|
||||
assert all(call.startswith("frontend=noninteractive ") for call in calls)
|
||||
|
||||
|
||||
def test_install_gives_up_on_a_mirror_that_never_answers(tmp_path):
|
||||
result, calls = _run(tmp_path, apt_body="exec sleep 60")
|
||||
|
||||
assert "Timed out installing ffmpeg after 1s" in result.stdout
|
||||
assert "ffmpeg installed" not in result.stdout
|
||||
# Killed during the download, so dpkg's install step never ran.
|
||||
assert not any("--no-download" in call for call in calls)
|
||||
|
||||
|
||||
def test_install_reports_a_failure(tmp_path):
|
||||
result, calls = _run(tmp_path, apt_body="exit 100")
|
||||
|
||||
assert "Could not install ffmpeg (exit 100)" in result.stdout
|
||||
assert not any("--no-download" in call for call in calls)
|
||||
|
||||
|
||||
def test_install_does_not_claim_success_when_ffmpeg_does_not_run(tmp_path):
|
||||
result, _ = _run(tmp_path, apt_body="exit 0", ffmpeg_works=False)
|
||||
|
||||
assert "ffmpeg does not run" in result.stdout
|
||||
assert "ffmpeg installed" not in result.stdout
|
||||
|
||||
|
||||
def test_install_starts_after_the_log_pipe_is_released():
|
||||
"""stop_file_logging waits for the log pipe to close, so the job must not hold it."""
|
||||
text = ENTRYPOINT_PATH.read_text()
|
||||
|
||||
assert text.rindex("stop_file_logging\n") < text.index('if [ "$INSTALL_FFMPEG" = "true" ]')
|
||||
assert text.index('if [ "$INSTALL_FFMPEG" = "true" ]') < text.rindex("exec_as_target_user env")
|
||||
@@ -384,7 +384,7 @@ class TestActivityRoutes:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="builtin"),
|
||||
patch.object(main_module, "send_file", side_effect=failure),
|
||||
patch("shelfmark.core.queue_routes.send_file", side_effect=failure),
|
||||
):
|
||||
response = client.get(f"/api/localdownload?id={task_id}")
|
||||
finally:
|
||||
@@ -1404,7 +1404,8 @@ class TestActivityRoutes:
|
||||
assert response.status_code == 200
|
||||
assert "stale-active-task" in response.json["status"]["error"]
|
||||
assert (
|
||||
response.json["status"]["error"]["stale-active-task"]["status_message"] == "Interrupted"
|
||||
response.json["status"]["error"]["stale-active-task"]["status_message"]
|
||||
== "Interrupted by a restart. Use Retry to run it again."
|
||||
)
|
||||
|
||||
def test_snapshot_preserves_retry_for_stale_active_requested_download(
|
||||
@@ -1453,7 +1454,9 @@ class TestActivityRoutes:
|
||||
|
||||
assert response.status_code == 200
|
||||
viewer_id = public_download_id(task_id)
|
||||
assert response.json["status"]["error"][viewer_id]["status_message"] == "Interrupted"
|
||||
assert response.json["status"]["error"][viewer_id]["status_message"].startswith(
|
||||
"Interrupted by a restart"
|
||||
)
|
||||
assert response.json["status"]["error"][viewer_id]["retry_available"] is True
|
||||
|
||||
def test_snapshot_includes_retry_available_for_live_terminal_downloads(
|
||||
@@ -1661,7 +1664,7 @@ class TestActivityRoutes:
|
||||
viewer_scope=f"user:{user['id']}",
|
||||
)
|
||||
|
||||
main_module._record_download_queued(
|
||||
main_module.backend.book_queue._queue_hook(
|
||||
"task-reused-1",
|
||||
SimpleNamespace(
|
||||
user_id=user["id"],
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core import download_recording
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus
|
||||
from shelfmark.core.notifications import NotificationEvent
|
||||
from shelfmark.core.viewer_redaction import public_download_id
|
||||
@@ -172,8 +173,8 @@ class TestTerminalSnapshotCapture:
|
||||
assert main_module.backend.book_queue.add(task) is True
|
||||
|
||||
try:
|
||||
with patch.object(main_module, "notify_admin") as mock_notify:
|
||||
with patch.object(main_module, "notify_user") as mock_notify_user:
|
||||
with patch.object(download_recording, "notify_admin") as mock_notify:
|
||||
with patch.object(download_recording, "notify_user") as mock_notify_user:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
|
||||
mock_notify.assert_called_once()
|
||||
@@ -264,8 +265,8 @@ class TestTerminalSnapshotCapture:
|
||||
|
||||
try:
|
||||
main_module.backend.book_queue.update_status_message(task_id, "Resolver timed out")
|
||||
with patch.object(main_module, "notify_admin") as mock_notify:
|
||||
with patch.object(main_module, "notify_user") as mock_notify_user:
|
||||
with patch.object(download_recording, "notify_admin") as mock_notify:
|
||||
with patch.object(download_recording, "notify_user") as mock_notify_user:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
|
||||
mock_notify.assert_called_once()
|
||||
@@ -324,7 +325,7 @@ class TestTerminalSnapshotCapture:
|
||||
main_module.backend.book_queue.update_status_message(
|
||||
task_id, "Destination not writable"
|
||||
)
|
||||
with patch.object(main_module, "reopen_failed_request") as mock_reopen:
|
||||
with patch.object(download_recording, "reopen_failed_request") as mock_reopen:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
|
||||
mock_reopen.assert_not_called()
|
||||
@@ -463,7 +464,7 @@ class TestTerminalSnapshotCapture:
|
||||
|
||||
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
|
||||
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
|
||||
main_module._record_download_queued(task_id, task)
|
||||
main_module.backend.book_queue._queue_hook(task_id, task)
|
||||
|
||||
mock_emit.assert_any_call(
|
||||
"activity_update",
|
||||
@@ -501,7 +502,7 @@ class TestTerminalSnapshotCapture:
|
||||
|
||||
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
|
||||
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
|
||||
main_module._record_download_queued(task_id, task)
|
||||
main_module.backend.book_queue._queue_hook(task_id, task)
|
||||
|
||||
payloads = _activity_update_payloads(mock_emit)
|
||||
assert payloads["admins"] == {"kind": "activity_reset", "task_id": task_id}
|
||||
@@ -524,8 +525,8 @@ class TestTerminalSnapshotCapture:
|
||||
assert main_module.backend.book_queue.add(task) is True
|
||||
|
||||
try:
|
||||
with patch.object(main_module, "notify_admin") as mock_notify:
|
||||
with patch.object(main_module, "notify_user") as mock_notify_user:
|
||||
with patch.object(download_recording, "notify_admin") as mock_notify:
|
||||
with patch.object(download_recording, "notify_user") as mock_notify_user:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.CANCELLED)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
@@ -14,6 +14,11 @@ import pytest
|
||||
from shelfmark.core import api_key
|
||||
|
||||
|
||||
def _hook(main_module, name):
|
||||
"""The registered before_request hook called `name` (it lives in core/auth_middleware.py)."""
|
||||
return next(f for f in main_module.app.before_request_funcs[None] if f.__name__ == name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_db():
|
||||
from shelfmark.core.user_db import UserDB
|
||||
@@ -111,7 +116,7 @@ def _identity_for_key(wired):
|
||||
the middleware in a request context sidesteps that entirely.
|
||||
"""
|
||||
with wired.app.test_request_context("/api/downloads/active", headers=_bearer("s3cret")):
|
||||
assert wired.api_key_auth_middleware() is None
|
||||
assert _hook(wired, "api_key_auth_middleware")() is None
|
||||
from flask import session
|
||||
|
||||
return dict(session)
|
||||
@@ -492,7 +497,7 @@ class TestReadOnlyKeyRequests:
|
||||
def test_never_gets_an_admin_session(self, ro, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
with ro.app.test_request_context("/api/stats", headers=_bearer("ro-key")):
|
||||
assert ro.api_key_auth_middleware() is None
|
||||
assert _hook(ro, "api_key_auth_middleware")() is None
|
||||
from flask import session
|
||||
|
||||
assert "is_admin" not in session
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Characterization tests for the app setup that moves out of main.py: the persisted secret key,
|
||||
the session cookie settings, the security headers and the werkzeug log filter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.app_setup import (
|
||||
FLASK_SECRET_KEY_MIN_BYTES,
|
||||
LogNoiseFilter,
|
||||
load_or_create_secret_key,
|
||||
register_security_headers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
def _record(message: str, *, level: int = logging.INFO, exc_info=None) -> logging.LogRecord:
|
||||
return logging.LogRecord("werkzeug", level, __file__, 1, message, None, exc_info)
|
||||
|
||||
|
||||
class TestSecretKey:
|
||||
def test_a_persisted_key_is_reused(self, main_module, tmp_path):
|
||||
persisted = b"k" * 64
|
||||
(tmp_path / ".flask_secret").write_bytes(persisted)
|
||||
|
||||
assert load_or_create_secret_key(tmp_path) == persisted
|
||||
|
||||
def test_a_missing_key_is_created_and_kept_private(self, main_module, tmp_path):
|
||||
created = load_or_create_secret_key(tmp_path)
|
||||
again = load_or_create_secret_key(tmp_path)
|
||||
|
||||
path = tmp_path / ".flask_secret"
|
||||
assert len(created) == 64
|
||||
assert path.read_bytes() == created == again
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
def test_the_key_is_never_written_to_a_file_others_can_read(
|
||||
self, main_module, tmp_path, monkeypatch
|
||||
):
|
||||
# Writing first and tightening the mode after leaves the key readable under the umask until
|
||||
# the chmod, and readable for good when the chmod fails.
|
||||
def chmod_fails(*_args, **_kwargs):
|
||||
raise OSError("chmod not supported here")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.chmod", chmod_fails)
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
key = load_or_create_secret_key(tmp_path)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
path = tmp_path / ".flask_secret"
|
||||
assert path.read_bytes() == key
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
def test_a_replaced_key_file_is_made_private(self, main_module, tmp_path):
|
||||
path = tmp_path / ".flask_secret"
|
||||
path.write_bytes(b"short")
|
||||
path.chmod(0o644)
|
||||
|
||||
key = load_or_create_secret_key(tmp_path)
|
||||
|
||||
assert path.read_bytes() == key
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
def test_a_key_that_is_too_short_is_replaced(self, main_module, tmp_path):
|
||||
path = tmp_path / ".flask_secret"
|
||||
path.write_bytes(b"short")
|
||||
|
||||
key = load_or_create_secret_key(tmp_path)
|
||||
|
||||
assert len(key) == 64
|
||||
assert path.read_bytes() == key
|
||||
|
||||
def test_a_key_of_exactly_the_minimum_length_is_kept(self, main_module, tmp_path):
|
||||
minimum = b"m" * FLASK_SECRET_KEY_MIN_BYTES
|
||||
(tmp_path / ".flask_secret").write_bytes(minimum)
|
||||
|
||||
assert load_or_create_secret_key(tmp_path) == minimum
|
||||
|
||||
def test_an_unwritable_config_dir_still_gives_a_key(self, main_module, tmp_path):
|
||||
blocked = tmp_path / "file-not-dir"
|
||||
blocked.write_text("x")
|
||||
|
||||
key = load_or_create_secret_key(blocked / "config")
|
||||
|
||||
assert len(key) == 64
|
||||
|
||||
def test_the_app_and_the_opaque_id_key_use_the_same_secret(self, main_module):
|
||||
from shelfmark.core import viewer_redaction
|
||||
|
||||
assert len(main_module.app.config["SECRET_KEY"]) >= 32
|
||||
assert viewer_redaction._public_id_key is not None
|
||||
|
||||
|
||||
class TestSessionCookie:
|
||||
def test_cookie_settings(self, main_module):
|
||||
config = main_module.app.config
|
||||
|
||||
assert config["SESSION_COOKIE_HTTPONLY"] is True
|
||||
assert config["SESSION_COOKIE_SAMESITE"] == "Lax"
|
||||
assert config["SESSION_COOKIE_NAME"] == main_module.SESSION_COOKIE_NAME
|
||||
assert config["SESSION_COOKIE_SECURE"] == main_module.SESSION_COOKIE_SECURE
|
||||
assert config["PERMANENT_SESSION_LIFETIME"] == 604800
|
||||
|
||||
|
||||
class TestSecurityHeaders:
|
||||
def test_every_response_carries_the_baseline_headers(self, main_module):
|
||||
response = main_module.app.test_client().get("/api/health")
|
||||
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert response.headers["Permissions-Policy"] == "camera=(), microphone=(), geolocation=()"
|
||||
assert response.headers["Cross-Origin-Embedder-Policy"] == "credentialless"
|
||||
csp = response.headers["Content-Security-Policy"]
|
||||
assert csp.startswith("default-src 'self'; script-src 'self';")
|
||||
assert "connect-src 'self' ws: wss:" in csp
|
||||
|
||||
def test_a_header_already_on_the_response_is_not_overridden(self, main_module):
|
||||
from flask import Flask, Response
|
||||
|
||||
response = Response("{}")
|
||||
response.headers["X-Content-Type-Options"] = "route-value"
|
||||
|
||||
app = Flask(__name__)
|
||||
register_security_headers(app)
|
||||
with app.test_request_context():
|
||||
result = app.process_response(response)
|
||||
|
||||
assert result is response
|
||||
assert response.headers["X-Content-Type-Options"] == "route-value"
|
||||
assert response.headers["Permissions-Policy"] == "camera=(), microphone=(), geolocation=()"
|
||||
|
||||
|
||||
class TestLogNoiseFilter:
|
||||
def test_status_polling_is_dropped(self, main_module):
|
||||
noise = LogNoiseFilter()
|
||||
|
||||
assert noise.filter(_record('127.0.0.1 - "GET /api/status HTTP/1.1" 200 -')) is False
|
||||
|
||||
def test_other_requests_are_kept(self, main_module):
|
||||
noise = LogNoiseFilter()
|
||||
|
||||
assert noise.filter(_record('127.0.0.1 - "GET /api/releases HTTP/1.1" 200 -')) is True
|
||||
assert noise.filter(_record('127.0.0.1 - "POST /api/status HTTP/1.1" 200 -')) is True
|
||||
|
||||
def test_the_websocket_upgrade_noise_is_dropped(self, main_module):
|
||||
noise = LogNoiseFilter()
|
||||
|
||||
assert noise.filter(_record("write() before start_response")) is False
|
||||
assert noise.filter(_record("Error on request:", level=logging.ERROR)) is False
|
||||
|
||||
def test_an_assertion_error_from_the_upgrade_is_dropped_but_other_errors_are_not(
|
||||
self, main_module
|
||||
):
|
||||
noise = LogNoiseFilter()
|
||||
upgrade = AssertionError("write() before start_response")
|
||||
other = AssertionError("something else")
|
||||
|
||||
dropped = _record("boom", level=logging.ERROR, exc_info=(AssertionError, upgrade, None))
|
||||
kept = _record("boom", level=logging.ERROR, exc_info=(AssertionError, other, None))
|
||||
kept_type = _record("boom", level=logging.ERROR, exc_info=(ValueError, upgrade, None))
|
||||
|
||||
assert noise.filter(dropped) is False
|
||||
assert noise.filter(kept) is True
|
||||
assert noise.filter(kept_type) is True
|
||||
|
||||
def test_the_filter_is_installed_on_the_werkzeug_logger(self, main_module):
|
||||
filters = logging.getLogger("werkzeug").filters
|
||||
|
||||
assert any(type(f).__name__ == "LogNoiseFilter" for f in filters)
|
||||
@@ -10,6 +10,11 @@ import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def _hook(main_module, name):
|
||||
"""The registered before_request hook called `name` (it lives in core/auth_middleware.py)."""
|
||||
return next(f for f in main_module.app.before_request_funcs[None] if f.__name__ == name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
@@ -309,7 +314,7 @@ class TestProxyProvisioningRole:
|
||||
):
|
||||
main_module.session["user_id"] = "bob"
|
||||
main_module.session["db_user_id"] = bob["id"]
|
||||
assert main_module.proxy_auth_middleware() is None
|
||||
assert _hook(main_module, "proxy_auth_middleware")() is None
|
||||
return main_module.session["is_admin"]
|
||||
|
||||
assert run_middleware() is False
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Pin the API-key and proxy-auth request hooks registered on the Flask app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Response, g, session
|
||||
|
||||
from shelfmark.core import api_key
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _empty_user_table(main_module):
|
||||
def _clear() -> None:
|
||||
for user in main_module.user_db.list_users():
|
||||
main_module.user_db.delete_user(user["id"])
|
||||
|
||||
_clear()
|
||||
yield
|
||||
_clear()
|
||||
|
||||
|
||||
def _hook(app, kind: str, name: str):
|
||||
funcs = app.before_request_funcs if kind == "before" else app.after_request_funcs
|
||||
for func in funcs[None]:
|
||||
if func.__name__ == name:
|
||||
return func
|
||||
raise AssertionError(f"{kind}_request hook {name} is not registered")
|
||||
|
||||
|
||||
def _config_getter(values: dict[str, Any]):
|
||||
def _get(key: str, default: Any = None, user_id: Any = None):
|
||||
return values.get(key, default)
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
def _proxy(main_module, path="/api/releases", *, headers=None, environ=None, config=None):
|
||||
"""Run the proxy hook in a request context and return (result, session dict)."""
|
||||
values = {"PROXY_AUTH_USER_HEADER": "X-Auth-User", **(config or {})}
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="proxy"),
|
||||
patch.object(main_module.app_config, "get", side_effect=_config_getter(values)),
|
||||
main_module.app.test_request_context(path, headers=headers, environ_base=environ),
|
||||
):
|
||||
result = _hook(main_module.app, "before", "proxy_auth_middleware")()
|
||||
return result, dict(session), session.permanent
|
||||
|
||||
|
||||
class TestHookOrder:
|
||||
def test_before_request_order(self, main_module):
|
||||
names = [f.__name__ for f in main_module.app.before_request_funcs[None]]
|
||||
assert names.index("api_key_auth_middleware") < names.index("proxy_auth_middleware")
|
||||
|
||||
def test_after_request_order(self, main_module):
|
||||
names = [f.__name__ for f in main_module.app.after_request_funcs[None]]
|
||||
assert names.index("set_security_headers") < names.index(
|
||||
"strip_cookie_for_api_key_requests"
|
||||
)
|
||||
|
||||
|
||||
class TestProxyGroups:
|
||||
@pytest.mark.parametrize(
|
||||
("groups", "expected"),
|
||||
[
|
||||
("admins", True),
|
||||
("users,admins", True),
|
||||
("users, admins ,other", True),
|
||||
("users|admins", True),
|
||||
("users", False),
|
||||
("", False),
|
||||
("administrators", False),
|
||||
],
|
||||
)
|
||||
def test_group_header_maps_to_admin(self, main_module, groups, expected):
|
||||
headers = {"X-Auth-User": "gina"}
|
||||
if groups:
|
||||
headers["X-Auth-Groups"] = groups
|
||||
result, sess, permanent = _proxy(
|
||||
main_module, headers=headers, config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"}
|
||||
)
|
||||
assert result is None
|
||||
assert sess["is_admin"] is expected
|
||||
assert permanent is False
|
||||
db_user = main_module.user_db.get_user(user_id=sess["db_user_id"])
|
||||
assert db_user["role"] == ("admin" if expected else "user")
|
||||
|
||||
def test_custom_group_header(self, main_module):
|
||||
result, sess, _ = _proxy(
|
||||
main_module,
|
||||
headers={"X-Auth-User": "gina", "Remote-Groups": "ops"},
|
||||
config={
|
||||
"PROXY_AUTH_ADMIN_GROUP_NAME": "ops",
|
||||
"PROXY_AUTH_ADMIN_GROUP_HEADER": "Remote-Groups",
|
||||
},
|
||||
)
|
||||
assert result is None
|
||||
assert sess["is_admin"] is True
|
||||
|
||||
def test_group_header_wsgi_environ_fallback(self, main_module):
|
||||
result, sess, _ = _proxy(
|
||||
main_module,
|
||||
headers={"X-Auth-User": "gina"},
|
||||
environ={"HTTP_X_AUTH_GROUPS": "admins"},
|
||||
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
|
||||
)
|
||||
assert result is None
|
||||
assert sess["is_admin"] is True
|
||||
|
||||
def test_configured_group_overrides_stored_role(self, main_module):
|
||||
main_module.user_db.create_user(username="gina", role="admin", auth_source="proxy")
|
||||
result, sess, _ = _proxy(
|
||||
main_module,
|
||||
headers={"X-Auth-User": "gina", "X-Auth-Groups": "users"},
|
||||
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
|
||||
)
|
||||
assert result is None
|
||||
assert sess["is_admin"] is False
|
||||
|
||||
def test_blank_user_header_setting_falls_back_to_default(self, main_module):
|
||||
result, sess, _ = _proxy(
|
||||
main_module,
|
||||
headers={"X-Auth-User": "gina"},
|
||||
config={"PROXY_AUTH_USER_HEADER": " "},
|
||||
)
|
||||
assert result is None
|
||||
assert sess["user_id"] == "gina"
|
||||
|
||||
|
||||
class TestProxyHeaderHandling:
|
||||
def test_missing_header_on_protected_path_is_401(self, main_module):
|
||||
result, sess, _ = _proxy(main_module)
|
||||
resp, status = result
|
||||
assert status == 401
|
||||
assert resp.get_json() == {"error": "Authentication required. Proxy header not set."}
|
||||
assert "user_id" not in sess
|
||||
|
||||
def test_missing_header_on_auth_path_passes(self, main_module):
|
||||
result, sess, _ = _proxy(main_module, "/api/auth/login")
|
||||
assert result is None
|
||||
assert "user_id" not in sess
|
||||
|
||||
def test_non_api_path_without_header_is_401(self, main_module):
|
||||
result, _, _ = _proxy(main_module, "/")
|
||||
assert result[1] == 401
|
||||
|
||||
def test_header_ignored_outside_proxy_mode(self, main_module):
|
||||
for mode in ("builtin", "none", "cwa"):
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value=mode),
|
||||
main_module.app.test_request_context(
|
||||
"/api/releases", headers={"X-Auth-User": "mallory"}
|
||||
),
|
||||
):
|
||||
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
|
||||
assert "user_id" not in session
|
||||
assert main_module.user_db.get_user(username="mallory") is None
|
||||
|
||||
def test_custom_user_header_is_case_insensitive(self, main_module):
|
||||
result, sess, _ = _proxy(
|
||||
main_module,
|
||||
headers={"x-forwarded-user": "gina"},
|
||||
config={"PROXY_AUTH_USER_HEADER": "X-Forwarded-User"},
|
||||
)
|
||||
assert result is None
|
||||
assert sess["user_id"] == "gina"
|
||||
|
||||
def test_provisioned_once_per_session(self, main_module):
|
||||
_, sess, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
|
||||
first_id = sess["db_user_id"]
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="proxy"),
|
||||
main_module.app.test_request_context("/api/releases", headers={"X-Auth-User": "gina"}),
|
||||
):
|
||||
session["user_id"] = "gina"
|
||||
session["db_user_id"] = first_id
|
||||
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
|
||||
assert session["db_user_id"] == first_id
|
||||
assert len(main_module.user_db.list_users()) == 1
|
||||
|
||||
def test_store_error_is_500(self, main_module):
|
||||
with patch.object(main_module.user_db, "get_user_cached", side_effect=RuntimeError("boom")):
|
||||
result, _, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
|
||||
resp, status = result
|
||||
assert status == 500
|
||||
assert resp.get_json() == {"error": "Authentication error"}
|
||||
|
||||
|
||||
class TestApiKeyScope:
|
||||
@pytest.fixture
|
||||
def keyed(self, main_module, monkeypatch):
|
||||
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
||||
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY_READONLY", "ro-key")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
yield main_module
|
||||
|
||||
def _run(self, main_module, path, key="s3cret", method="GET"):
|
||||
with main_module.app.test_request_context(path, method=method, headers={"X-Api-Key": key}):
|
||||
result = _hook(main_module.app, "before", "api_key_auth_middleware")()
|
||||
return result, dict(session), g.get("api_key_auth"), g.get("api_key_scope")
|
||||
|
||||
@pytest.mark.parametrize("path", ["/", "/index.html", "/static/app.js", "/apix", "/api"])
|
||||
def test_non_api_paths_ignore_key(self, keyed, path):
|
||||
result, sess, flag, scope = self._run(keyed, path)
|
||||
assert result is None
|
||||
assert sess == {}
|
||||
assert flag is None
|
||||
assert scope is None
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/health", "/api/auth/check", "/api/auth/login"])
|
||||
def test_exempt_paths_ignore_key(self, keyed, path):
|
||||
result, sess, flag, _ = self._run(keyed, path)
|
||||
assert result is None
|
||||
assert sess == {}
|
||||
assert flag is None
|
||||
|
||||
def test_admin_key_marks_request_and_sets_admin_session(self, keyed):
|
||||
admin = keyed.user_db.create_user(username="root", role="admin")
|
||||
result, sess, flag, scope = self._run(keyed, "/api/downloads/active")
|
||||
assert result is None
|
||||
assert flag is True
|
||||
assert scope == "admin"
|
||||
assert sess["user_id"] == "root"
|
||||
assert sess["is_admin"] is True
|
||||
assert sess["db_user_id"] == admin["id"]
|
||||
|
||||
def test_admin_key_without_admin_account_uses_api_identity(self, keyed):
|
||||
_, sess, _, _ = self._run(keyed, "/api/downloads/active")
|
||||
assert sess["user_id"] == "api"
|
||||
assert sess["is_admin"] is True
|
||||
assert "db_user_id" not in sess
|
||||
|
||||
def test_admin_key_session_is_not_persisted(self, keyed):
|
||||
with keyed.app.test_request_context(
|
||||
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
|
||||
):
|
||||
_hook(keyed.app, "before", "api_key_auth_middleware")()
|
||||
assert session.permanent is False
|
||||
assert session.modified is False
|
||||
|
||||
def test_wrong_key_falls_through_untouched(self, keyed):
|
||||
result, sess, flag, scope = self._run(keyed, "/api/downloads/active", key="nope")
|
||||
assert result is None
|
||||
assert sess == {}
|
||||
assert flag is None
|
||||
assert scope is None
|
||||
|
||||
def test_readonly_key_allowed_on_stats_get_only(self, keyed):
|
||||
result, sess, flag, scope = self._run(keyed, "/api/stats", key="ro-key")
|
||||
assert result is None
|
||||
assert (sess, flag, scope) == ({}, True, "readonly")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "method"),
|
||||
[("/api/stats", "POST"), ("/api/downloads/active", "GET"), ("/api/stats/x", "GET")],
|
||||
)
|
||||
def test_readonly_key_refused_elsewhere(self, keyed, path, method):
|
||||
result, _, flag, scope = self._run(keyed, path, key="ro-key", method=method)
|
||||
resp, status = result
|
||||
assert status == 403
|
||||
assert resp.get_json() == {"error": "This API key is read-only"}
|
||||
assert flag is True
|
||||
assert scope == "readonly"
|
||||
|
||||
def test_none_mode_ignores_key(self, main_module, monkeypatch):
|
||||
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
result, sess, flag, _ = self._run(main_module, "/api/downloads/active")
|
||||
assert (result, sess, flag) == (None, {}, None)
|
||||
|
||||
def test_store_error_is_500_and_still_marked_keyed(self, keyed):
|
||||
with patch.object(keyed.user_db, "get_first_admin", side_effect=RuntimeError("boom")):
|
||||
result, _, flag, _ = self._run(keyed, "/api/downloads/active")
|
||||
resp, status = result
|
||||
assert status == 500
|
||||
assert resp.get_json() == {"error": "Authentication error"}
|
||||
assert flag is True
|
||||
|
||||
def test_proxy_mode_keyed_request_skips_proxy_hook(self, keyed):
|
||||
with (
|
||||
patch.object(keyed, "get_auth_mode", return_value="proxy"),
|
||||
keyed.app.test_request_context(
|
||||
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
|
||||
),
|
||||
):
|
||||
assert _hook(keyed.app, "before", "api_key_auth_middleware")() is None
|
||||
assert _hook(keyed.app, "before", "proxy_auth_middleware")() is None
|
||||
assert session["is_admin"] is True
|
||||
|
||||
|
||||
class TestCookieStripping:
|
||||
def _strip(self, main_module, *, keyed: bool):
|
||||
with main_module.app.test_request_context("/api/downloads/active"):
|
||||
if keyed:
|
||||
g.api_key_auth = True
|
||||
session["dirty"] = True
|
||||
session.permanent = True
|
||||
resp = _hook(main_module.app, "after", "strip_cookie_for_api_key_requests")(
|
||||
Response("ok")
|
||||
)
|
||||
return resp, session.modified, session.permanent
|
||||
|
||||
def test_keyed_request_resets_session_flags(self, main_module):
|
||||
resp, modified, permanent = self._strip(main_module, keyed=True)
|
||||
assert resp.get_data(as_text=True) == "ok"
|
||||
assert modified is False
|
||||
assert permanent is False
|
||||
|
||||
def test_unkeyed_request_is_untouched(self, main_module):
|
||||
_, modified, permanent = self._strip(main_module, keyed=False)
|
||||
assert modified is True
|
||||
assert permanent is True
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Characterization tests for `/api/auth/login`, `/api/auth/logout` and `/api/auth/check`.
|
||||
|
||||
They pin the behaviour the three routes have today (lockout thresholds and timing,
|
||||
messages, status codes, session writes, one case per auth mode), so a refactor that
|
||||
moves the routes can be checked against them. Every credential here is made up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(main_module):
|
||||
main_module.failed_login_attempts.clear()
|
||||
try:
|
||||
yield main_module.app.test_client()
|
||||
finally:
|
||||
main_module.failed_login_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_user_db(tmp_path, main_module, monkeypatch):
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
db = UserDB(str(tmp_path / "users.db"))
|
||||
db.initialize()
|
||||
monkeypatch.setattr(main_module, "user_db", db)
|
||||
return db
|
||||
|
||||
|
||||
def _mode(main_module, mode):
|
||||
return patch.object(main_module, "get_auth_mode", return_value=mode)
|
||||
|
||||
|
||||
def _login(client, username="alice", password="fake-1", **extra):
|
||||
return client.post(
|
||||
"/api/auth/login", json={"username": username, "password": password, **extra}
|
||||
)
|
||||
|
||||
|
||||
def _make_alice(db, role="user"):
|
||||
return db.create_user(
|
||||
username="alice", password_hash=generate_password_hash("fake-1"), role=role
|
||||
)
|
||||
|
||||
|
||||
class TestLoginInput:
|
||||
def test_empty_json_object_is_no_data(self, main_module, client):
|
||||
with _mode(main_module, "builtin"):
|
||||
response = client.post("/api/auth/login", json={})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "No data provided"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"username": "", "password": "x"},
|
||||
{"username": " ", "password": "x"},
|
||||
{"username": "alice", "password": ""},
|
||||
{"username": "alice"},
|
||||
{"password": "x"},
|
||||
],
|
||||
)
|
||||
def test_missing_username_or_password(self, main_module, client, payload):
|
||||
with _mode(main_module, "builtin"):
|
||||
response = client.post("/api/auth/login", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "Username and password are required"}
|
||||
assert main_module.failed_login_attempts == {}
|
||||
|
||||
def test_username_is_stripped(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, username=" alice ")
|
||||
assert response.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "alice"
|
||||
|
||||
def test_proxy_mode_wins_over_missing_data_checks_order(self, main_module, client):
|
||||
# Proxy mode answers before the username/password check.
|
||||
with _mode(main_module, "proxy"):
|
||||
response = client.post("/api/auth/login", json={"username": "", "password": ""})
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Proxy authentication is enabled"}
|
||||
|
||||
|
||||
class TestLoginBuiltin:
|
||||
def test_success_sets_session_cookie_and_clears_failures(
|
||||
self, main_module, client, temp_user_db
|
||||
):
|
||||
user = _make_alice(temp_user_db, role="user")
|
||||
main_module.failed_login_attempts["alice"] = {"count": 3}
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, remember_me=True)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
assert any(
|
||||
header.startswith(main_module.app.config["SESSION_COOKIE_NAME"] + "=")
|
||||
for header in response.headers.getlist("Set-Cookie")
|
||||
)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "alice"
|
||||
assert sess["db_user_id"] == user["id"]
|
||||
assert sess["is_admin"] is False
|
||||
assert sess.permanent is True
|
||||
assert "alice" not in main_module.failed_login_attempts
|
||||
|
||||
def test_oidc_mode_allows_password_fallback(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db, role="admin")
|
||||
with _mode(main_module, "oidc"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["is_admin"] is True
|
||||
|
||||
def test_unknown_user_is_counted_and_gets_the_generic_message(
|
||||
self, main_module, client, temp_user_db
|
||||
):
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, username="nobody")
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Invalid username or password."}
|
||||
assert main_module.failed_login_attempts["nobody"]["count"] == 1
|
||||
with client.session_transaction() as sess:
|
||||
assert "user_id" not in sess
|
||||
|
||||
def test_user_without_password_hash_is_rejected(self, main_module, client, temp_user_db):
|
||||
temp_user_db.create_user(username="alice", role="user")
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 401
|
||||
assert main_module.failed_login_attempts["alice"]["count"] == 1
|
||||
|
||||
def test_wrong_password_writes_no_session(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, password="wrong")
|
||||
assert response.status_code == 401
|
||||
with client.session_transaction() as sess:
|
||||
assert "user_id" not in sess
|
||||
assert "is_admin" not in sess
|
||||
|
||||
def test_no_user_database_is_503(self, main_module, client, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "user_db", None)
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 503
|
||||
assert response.get_json() == {"error": "Authentication service unavailable"}
|
||||
|
||||
def test_database_error_is_500(self, main_module, client, temp_user_db, monkeypatch):
|
||||
def boom(**kwargs):
|
||||
raise sqlite3.OperationalError("fake failure")
|
||||
|
||||
monkeypatch.setattr(temp_user_db, "get_user", boom)
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Authentication system error"}
|
||||
|
||||
def test_unexpected_mode_is_500(self, main_module, client):
|
||||
with _mode(main_module, "unavailable"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Unknown authentication mode"}
|
||||
|
||||
|
||||
class TestLoginNoneMode:
|
||||
def test_any_credentials_succeed_and_clear_failures(self, main_module, client):
|
||||
main_module.failed_login_attempts["guest"] = {"count": 2}
|
||||
with _mode(main_module, "none"):
|
||||
response = _login(client, username="guest", password="any", remember_me=False)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "guest"
|
||||
assert "is_admin" not in sess
|
||||
assert sess.permanent is False
|
||||
assert "guest" not in main_module.failed_login_attempts
|
||||
|
||||
|
||||
class TestLoginCwa:
|
||||
@staticmethod
|
||||
def _cwa_db(path, name="cwa_fake", password="cwahash", role=0, email=None):
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
|
||||
(name, password, role, email),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return path
|
||||
|
||||
def test_success_non_admin(self, main_module, client, tmp_path, temp_user_db):
|
||||
path = self._cwa_db(tmp_path / "app.db", role=0, email="c@example.invalid")
|
||||
with (
|
||||
_mode(main_module, "cwa"),
|
||||
patch.object(main_module, "CWA_DB_PATH", path),
|
||||
patch.object(main_module, "check_password_hash", return_value=True),
|
||||
):
|
||||
response = _login(client, username="cwa_fake", remember_me=True)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "cwa_fake"
|
||||
assert sess["is_admin"] is False
|
||||
assert sess["db_user_id"] == temp_user_db.get_user(username="cwa_fake")["id"]
|
||||
assert sess.permanent is True
|
||||
|
||||
def test_admin_bit_makes_admin(self, main_module, client, tmp_path, temp_user_db):
|
||||
path = self._cwa_db(tmp_path / "app.db", role=3)
|
||||
with (
|
||||
_mode(main_module, "cwa"),
|
||||
patch.object(main_module, "CWA_DB_PATH", path),
|
||||
patch.object(main_module, "check_password_hash", return_value=True),
|
||||
):
|
||||
response = _login(client, username="cwa_fake")
|
||||
assert response.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["is_admin"] is True
|
||||
|
||||
def test_wrong_password_is_counted(self, main_module, client, tmp_path, temp_user_db):
|
||||
path = self._cwa_db(tmp_path / "app.db")
|
||||
with (
|
||||
_mode(main_module, "cwa"),
|
||||
patch.object(main_module, "CWA_DB_PATH", path),
|
||||
patch.object(main_module, "check_password_hash", return_value=False),
|
||||
):
|
||||
response = _login(client, username="cwa_fake")
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Invalid username or password."}
|
||||
assert main_module.failed_login_attempts["cwa_fake"]["count"] == 1
|
||||
|
||||
def test_unknown_cwa_user_is_counted(self, main_module, client, tmp_path, temp_user_db):
|
||||
path = self._cwa_db(tmp_path / "app.db")
|
||||
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", path):
|
||||
response = _login(client, username="someone_else")
|
||||
assert response.status_code == 401
|
||||
assert main_module.failed_login_attempts["someone_else"]["count"] == 1
|
||||
|
||||
def test_missing_database_is_500(self, main_module, client, tmp_path):
|
||||
with (
|
||||
_mode(main_module, "cwa"),
|
||||
patch.object(main_module, "CWA_DB_PATH", tmp_path / "gone.db"),
|
||||
):
|
||||
response = _login(client, username="cwa_fake")
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Database configuration error"}
|
||||
|
||||
def test_unset_database_path_is_500(self, main_module, client):
|
||||
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", None):
|
||||
response = _login(client, username="cwa_fake")
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Database configuration error"}
|
||||
|
||||
def test_unreadable_database_is_500(self, main_module, client, tmp_path):
|
||||
path = tmp_path / "app.db"
|
||||
path.write_text("this is not a sqlite file")
|
||||
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", path):
|
||||
response = _login(client, username="cwa_fake")
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Authentication system error"}
|
||||
|
||||
|
||||
class TestLoginLockout:
|
||||
def test_warning_starts_at_five_remaining(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
messages = []
|
||||
with _mode(main_module, "builtin"):
|
||||
for _ in range(9):
|
||||
response = _login(client, password="wrong")
|
||||
assert response.status_code == 401
|
||||
messages.append(response.get_json()["error"])
|
||||
assert messages[:4] == ["Invalid username or password."] * 4
|
||||
assert messages[4] == "Invalid username or password. 5 attempts remaining."
|
||||
assert messages[8] == "Invalid username or password. 1 attempts remaining."
|
||||
assert main_module.failed_login_attempts["alice"]["count"] == 9
|
||||
assert "lockout_until" not in main_module.failed_login_attempts["alice"]
|
||||
|
||||
def test_tenth_failure_locks_for_thirty_minutes(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
before = datetime.now(UTC)
|
||||
with _mode(main_module, "builtin"):
|
||||
for _ in range(9):
|
||||
_login(client, password="wrong")
|
||||
response = _login(client, password="wrong")
|
||||
after = datetime.now(UTC)
|
||||
assert response.status_code == 429
|
||||
assert response.get_json() == {
|
||||
"error": "Account locked due to 10 failed login attempts. Try again in 30 minutes."
|
||||
}
|
||||
until = main_module.failed_login_attempts["alice"]["lockout_until"]
|
||||
assert before + timedelta(minutes=30) <= until <= after + timedelta(minutes=30)
|
||||
|
||||
def test_locked_account_refuses_even_the_right_password(
|
||||
self, main_module, client, temp_user_db
|
||||
):
|
||||
_make_alice(temp_user_db)
|
||||
main_module.failed_login_attempts["alice"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) + timedelta(minutes=12, seconds=30),
|
||||
}
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 429
|
||||
assert response.get_json() == {
|
||||
"error": "Account temporarily locked due to multiple failed login attempts. "
|
||||
"Try again in 12 minutes."
|
||||
}
|
||||
with client.session_transaction() as sess:
|
||||
assert "user_id" not in sess
|
||||
# A refused attempt does not extend the lockout or bump the counter.
|
||||
assert main_module.failed_login_attempts["alice"]["count"] == 10
|
||||
|
||||
def test_lockout_is_per_username(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
temp_user_db.create_user(
|
||||
username="bob", password_hash=generate_password_hash("fake-2"), role="user"
|
||||
)
|
||||
main_module.failed_login_attempts["alice"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
|
||||
}
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, username="bob", password="fake-2")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_lockout_applies_in_none_and_cwa_modes_too(self, main_module, client):
|
||||
main_module.failed_login_attempts["guest"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
|
||||
}
|
||||
with _mode(main_module, "none"):
|
||||
response = _login(client, username="guest")
|
||||
assert response.status_code == 429
|
||||
|
||||
def test_expired_lockout_lets_the_user_in_and_forgets_the_entry(
|
||||
self, main_module, client, temp_user_db
|
||||
):
|
||||
_make_alice(temp_user_db)
|
||||
main_module.failed_login_attempts["alice"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) - timedelta(seconds=1),
|
||||
}
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client)
|
||||
assert response.status_code == 200
|
||||
assert "alice" not in main_module.failed_login_attempts
|
||||
|
||||
def test_expired_lockout_restarts_the_count_after_a_failure(
|
||||
self, main_module, client, temp_user_db
|
||||
):
|
||||
_make_alice(temp_user_db)
|
||||
main_module.failed_login_attempts["alice"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) - timedelta(minutes=1),
|
||||
}
|
||||
with _mode(main_module, "builtin"):
|
||||
response = _login(client, password="wrong")
|
||||
assert response.status_code == 401
|
||||
assert main_module.failed_login_attempts["alice"] == {"count": 1}
|
||||
|
||||
def test_expiry_sweep_only_drops_expired_entries(self, main_module):
|
||||
now = datetime.now(UTC)
|
||||
main_module.failed_login_attempts.clear()
|
||||
main_module.failed_login_attempts.update(
|
||||
{
|
||||
"expired": {"count": 10, "lockout_until": now - timedelta(minutes=1)},
|
||||
"active": {"count": 10, "lockout_until": now + timedelta(minutes=1)},
|
||||
"partial": {"count": 4},
|
||||
}
|
||||
)
|
||||
try:
|
||||
assert main_module.is_account_locked("active") is True
|
||||
assert set(main_module.failed_login_attempts) == {"active", "partial"}
|
||||
finally:
|
||||
main_module.failed_login_attempts.clear()
|
||||
|
||||
def test_malformed_timestamp_below_threshold_is_ignored(self, main_module):
|
||||
main_module.failed_login_attempts.clear()
|
||||
main_module.failed_login_attempts["odd"] = {"count": 2, "lockout_until": "tomorrow"}
|
||||
try:
|
||||
assert main_module.is_account_locked("odd") is False
|
||||
finally:
|
||||
main_module.failed_login_attempts.clear()
|
||||
|
||||
def test_helpers_return_and_mutate_the_shared_state(self, main_module):
|
||||
main_module.failed_login_attempts.clear()
|
||||
state = main_module.failed_login_attempts
|
||||
try:
|
||||
for attempt in range(1, 10):
|
||||
assert main_module.record_failed_login("u", "203.0.113.9") is False
|
||||
assert state["u"]["count"] == attempt
|
||||
assert main_module.record_failed_login("u", "203.0.113.9") is True
|
||||
assert main_module.is_account_locked("u") is True
|
||||
main_module.clear_failed_logins("u")
|
||||
assert "u" not in state
|
||||
main_module.clear_failed_logins("never-seen")
|
||||
assert main_module.failed_login_attempts is state
|
||||
finally:
|
||||
main_module.failed_login_attempts.clear()
|
||||
|
||||
def test_limits(self, main_module):
|
||||
assert main_module.MAX_LOGIN_ATTEMPTS == 10
|
||||
assert main_module.LOCKOUT_DURATION_MINUTES == 30
|
||||
assert main_module.LOGIN_ATTEMPT_WARNING_THRESHOLD == 5
|
||||
|
||||
|
||||
class TestLoginLocalAuthSwitches:
|
||||
@pytest.mark.parametrize("mode", ["builtin", "oidc"])
|
||||
def test_disable_local_auth_refuses_before_the_lockout_check(self, main_module, client, mode):
|
||||
main_module.failed_login_attempts["alice"] = {
|
||||
"count": 10,
|
||||
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
|
||||
}
|
||||
with _mode(main_module, mode), patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
response = _login(client)
|
||||
assert response.status_code == 403
|
||||
assert response.get_json() == {"error": "Local authentication is disabled"}
|
||||
|
||||
def test_hide_local_auth_only_blocks_oidc(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
with _mode(main_module, "builtin"), patch.object(main_module, "HIDE_LOCAL_AUTH", True):
|
||||
assert _login(client).status_code == 200
|
||||
with _mode(main_module, "oidc"), patch.object(main_module, "HIDE_LOCAL_AUTH", True):
|
||||
assert _login(client).status_code == 403
|
||||
|
||||
def test_disable_local_auth_does_not_affect_none_mode(self, main_module, client):
|
||||
with _mode(main_module, "none"), patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
assert _login(client).status_code == 200
|
||||
|
||||
|
||||
class TestLoginClientIp:
|
||||
def test_the_client_address_is_looked_up_per_request(self, main_module, client, temp_user_db):
|
||||
_make_alice(temp_user_db)
|
||||
with (
|
||||
_mode(main_module, "builtin"),
|
||||
patch.object(main_module, "get_client_ip", return_value="198.51.100.7") as lookup,
|
||||
):
|
||||
response = _login(client, password="wrong")
|
||||
assert response.status_code == 401
|
||||
assert lookup.called
|
||||
|
||||
|
||||
class TestLogout:
|
||||
@pytest.mark.parametrize("mode", ["builtin", "oidc", "cwa", "none"])
|
||||
def test_clears_the_session_and_answers_success(self, main_module, client, mode):
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = "alice"
|
||||
sess["db_user_id"] = 1
|
||||
sess["is_admin"] = True
|
||||
with _mode(main_module, mode):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
with client.session_transaction() as sess:
|
||||
assert dict(sess) == {}
|
||||
|
||||
def test_without_a_session_still_succeeds(self, main_module, client):
|
||||
with _mode(main_module, "builtin"):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
|
||||
def test_proxy_without_a_configured_url_has_no_logout_url(self, main_module, client):
|
||||
with (
|
||||
_mode(main_module, "proxy"),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=lambda key, default=None, user_id=None: default,
|
||||
),
|
||||
):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.get_json() == {"success": True}
|
||||
|
||||
def test_a_non_proxy_mode_ignores_the_logout_url(self, main_module, client):
|
||||
with (
|
||||
_mode(main_module, "builtin"),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=lambda key, default=None, user_id=None: {
|
||||
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.invalid/out"
|
||||
}.get(key, default),
|
||||
),
|
||||
):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.get_json() == {"success": True}
|
||||
|
||||
def test_only_post_is_allowed(self, main_module):
|
||||
rules = [r for r in main_module.app.url_map.iter_rules() if r.rule == "/api/auth/logout"]
|
||||
assert [sorted(r.methods - {"OPTIONS"}) for r in rules] == [["POST"]]
|
||||
|
||||
def test_error_is_500(self, main_module, client):
|
||||
with (
|
||||
_mode(main_module, "builtin"),
|
||||
patch.object(main_module, "get_client_ip", side_effect=RuntimeError("fake")),
|
||||
):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Logout failed"}
|
||||
|
||||
|
||||
class TestAuthCheck:
|
||||
def test_none_mode_ignores_the_session(self, main_module, client):
|
||||
with _mode(main_module, "none"):
|
||||
response = client.get("/api/auth/check")
|
||||
assert response.get_json() == {
|
||||
"authenticated": True,
|
||||
"auth_required": False,
|
||||
"auth_mode": "none",
|
||||
"is_admin": True,
|
||||
}
|
||||
|
||||
def test_builtin_without_session(self, main_module, client, temp_user_db):
|
||||
with _mode(main_module, "builtin"):
|
||||
response = client.get("/api/auth/check")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"authenticated": False,
|
||||
"auth_required": True,
|
||||
"auth_mode": "builtin",
|
||||
"is_admin": False,
|
||||
"username": None,
|
||||
"display_name": None,
|
||||
}
|
||||
|
||||
def test_non_admin_session_reports_not_admin(self, main_module, client, temp_user_db):
|
||||
user = _make_alice(temp_user_db, role="user")
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = "alice"
|
||||
sess["db_user_id"] = user["id"]
|
||||
sess["is_admin"] = False
|
||||
with _mode(main_module, "builtin"):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert body["authenticated"] is True
|
||||
assert body["is_admin"] is False
|
||||
assert body["username"] == "alice"
|
||||
|
||||
def test_cwa_session_admin_flag_comes_from_the_session(self, main_module, client):
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = "cwa_fake"
|
||||
sess["is_admin"] = True
|
||||
with _mode(main_module, "cwa"):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert body == {
|
||||
"authenticated": True,
|
||||
"auth_required": True,
|
||||
"auth_mode": "cwa",
|
||||
"is_admin": True,
|
||||
"username": "cwa_fake",
|
||||
"display_name": None,
|
||||
}
|
||||
|
||||
def test_oidc_options(self, main_module, client):
|
||||
values = {"OIDC_BUTTON_LABEL": "Fake SSO"}
|
||||
with (
|
||||
_mode(main_module, "oidc"),
|
||||
patch.object(main_module, "HIDE_LOCAL_AUTH", True),
|
||||
patch.object(main_module, "OIDC_AUTO_REDIRECT", True),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=lambda key, default=None, user_id=None: values.get(key, default),
|
||||
),
|
||||
):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert body["oidc_button_label"] == "Fake SSO"
|
||||
assert body["hide_local_auth"] is True
|
||||
assert body["oidc_auto_redirect"] is True
|
||||
|
||||
def test_oidc_without_options_has_no_extra_keys(self, main_module, client):
|
||||
with (
|
||||
_mode(main_module, "oidc"),
|
||||
patch.object(main_module, "DISABLE_LOCAL_AUTH", False),
|
||||
patch.object(main_module, "HIDE_LOCAL_AUTH", False),
|
||||
patch.object(main_module, "OIDC_AUTO_REDIRECT", False),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=lambda key, default=None, user_id=None: default,
|
||||
),
|
||||
):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert set(body) == {
|
||||
"authenticated",
|
||||
"auth_required",
|
||||
"auth_mode",
|
||||
"is_admin",
|
||||
"username",
|
||||
"display_name",
|
||||
}
|
||||
|
||||
def test_proxy_logout_url_needs_the_user_header_setting(self, main_module, client):
|
||||
def build(values):
|
||||
return lambda key, default=None, user_id=None: values.get(key, default)
|
||||
|
||||
url = {"PROXY_AUTH_LOGOUT_URL": "https://auth.example.invalid/out"}
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = "p"
|
||||
sess["is_admin"] = False
|
||||
with (
|
||||
_mode(main_module, "proxy"),
|
||||
patch.object(main_module.app_config, "get", side_effect=build(url)),
|
||||
):
|
||||
assert "logout_url" not in client.get("/api/auth/check").get_json()
|
||||
with (
|
||||
_mode(main_module, "proxy"),
|
||||
patch.object(
|
||||
main_module.app_config,
|
||||
"get",
|
||||
side_effect=build({**url, "PROXY_AUTH_USER_HEADER": "X-Fake"}),
|
||||
),
|
||||
):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert body["logout_url"] == "https://auth.example.invalid/out"
|
||||
|
||||
def test_display_name_lookup_failure_is_ignored(
|
||||
self, main_module, client, temp_user_db, monkeypatch
|
||||
):
|
||||
user = _make_alice(temp_user_db)
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = "alice"
|
||||
sess["db_user_id"] = user["id"]
|
||||
sess["is_admin"] = False
|
||||
|
||||
def boom(**kwargs):
|
||||
raise sqlite3.Error("fake")
|
||||
|
||||
monkeypatch.setattr(temp_user_db, "get_user", boom)
|
||||
with _mode(main_module, "builtin"):
|
||||
body = client.get("/api/auth/check").get_json()
|
||||
assert body["authenticated"] is True
|
||||
assert body["display_name"] is None
|
||||
|
||||
def test_error_reports_unknown_mode(self, main_module, client):
|
||||
with (
|
||||
_mode(main_module, "builtin"),
|
||||
patch.object(
|
||||
main_module, "get_auth_check_admin_status", side_effect=RuntimeError("fake")
|
||||
),
|
||||
):
|
||||
response = client.get("/api/auth/check")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"authenticated": False,
|
||||
"auth_required": True,
|
||||
"auth_mode": "unknown",
|
||||
"is_admin": False,
|
||||
}
|
||||
|
||||
def test_only_get_is_allowed(self, main_module):
|
||||
rules = [r for r in main_module.app.url_map.iter_rules() if r.rule == "/api/auth/check"]
|
||||
assert [sorted(r.methods - {"OPTIONS"}) for r in rules] == [["GET", "HEAD"]]
|
||||
|
||||
|
||||
class TestLockoutCleanupRace:
|
||||
def test_cleanup_survives_an_entry_cleared_while_it_runs(self, main_module, client):
|
||||
# A successful login can clear an expired entry between the cleanup collecting it and
|
||||
# removing it (the log write in between is a yield point under gevent).
|
||||
from shelfmark.core import auth_routes
|
||||
|
||||
expired = datetime.now(UTC) - timedelta(minutes=1)
|
||||
main_module.failed_login_attempts["alice"] = {"count": 10, "lockout_until": expired}
|
||||
|
||||
def cleared_meanwhile(*_args, **_kwargs):
|
||||
auth_routes.clear_failed_logins("alice")
|
||||
|
||||
with patch.object(auth_routes.logger, "info", side_effect=cleared_meanwhile):
|
||||
auth_routes.cleanup_old_lockouts()
|
||||
|
||||
assert "alice" not in main_module.failed_login_attempts
|
||||
@@ -2621,6 +2621,9 @@ class TestRetryAfterCooldown:
|
||||
"Failed to add to qbittorrent: Failed to connect to qBittorrent. This i",
|
||||
"Cannot connect to qBittorrent at http://x",
|
||||
"Connection refused",
|
||||
"Failed to add to qbittorrent: qBittorrent authentication failed (HTTP 403)",
|
||||
"Failed to add to qbittorrent: qBittorrent authentication failed (HTTP 401)",
|
||||
"Failed to add to deluge: Deluge Web UI authentication failed",
|
||||
],
|
||||
)
|
||||
def test_a_download_whose_client_was_down_is_not_held_against_its_release(
|
||||
@@ -3620,3 +3623,166 @@ class TestHistoryOrder:
|
||||
== 1
|
||||
)
|
||||
assert suite._state(user_db, rid) == "cancelled"
|
||||
|
||||
|
||||
class TestOwnerPolicyGate:
|
||||
"""Auto-download fulfils a request only where its owner could have downloaded it directly.
|
||||
|
||||
It runs as an admin, so without this a request made under an approval policy would be
|
||||
approved by the pass instead of waiting for an admin.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _setup(monkeypatch, *, policy, auth_mode="builtin", searched=None):
|
||||
"""Turn auto-download on with two sources, the given policy and auth mode.
|
||||
|
||||
Every source has a match.
|
||||
"""
|
||||
monkeypatch.setattr(auto_download, "app_config", _Config(AUTO_DOWNLOAD_ENABLED=True))
|
||||
monkeypatch.setattr(
|
||||
auto_download, "_configured_source_priority", lambda _ct: ["first", "second"]
|
||||
)
|
||||
monkeypatch.setattr("shelfmark.core.auth_modes.load_active_auth_mode", lambda: auth_mode)
|
||||
monkeypatch.setattr(
|
||||
auto_download, "load_users_request_policy_settings", lambda: dict(policy)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.core.library_index.is_in_library", lambda *_args, **_kwargs: False
|
||||
)
|
||||
_stub_provider(monkeypatch, _book())
|
||||
|
||||
def _search(source_name, *_args, **_kwargs):
|
||||
"""Return one matching release from any source asked, recording its name."""
|
||||
if searched is not None:
|
||||
searched.append(source_name)
|
||||
return None, [_release(source=source_name)], None
|
||||
|
||||
monkeypatch.setattr(auto_download, "search_source_releases", _search)
|
||||
|
||||
@staticmethod
|
||||
def _queue(release_data, priority, *, user_id, username):
|
||||
return True, None
|
||||
|
||||
def _run(self, user_db, admin):
|
||||
return auto_download.auto_download_pending(
|
||||
user_db, queue_release=self._queue, admin_user_id=admin["id"]
|
||||
)
|
||||
|
||||
APPROVAL = {"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "request_book"}
|
||||
DIRECT = {"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download"}
|
||||
|
||||
def test_a_request_awaiting_approval_is_left_exactly_as_it_was(self, user_db, monkeypatch):
|
||||
searched: list[str] = []
|
||||
self._setup(monkeypatch, policy=self.APPROVAL, searched=searched)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
row = _pending_request(user_db, reader["id"])
|
||||
|
||||
summary = self._run(user_db, admin)
|
||||
|
||||
assert summary["queued"] == 0
|
||||
assert summary["awaiting_approval"] == 1
|
||||
assert searched == []
|
||||
# No backoff state, note or review: the same as a manual request awaiting review.
|
||||
assert user_db.get_request(row["id"]) == row
|
||||
|
||||
def test_a_request_the_owner_could_download_directly_is_fulfilled(self, user_db, monkeypatch):
|
||||
self._setup(monkeypatch, policy=self.DIRECT)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
row = _pending_request(user_db, reader["id"])
|
||||
|
||||
summary = self._run(user_db, admin)
|
||||
|
||||
assert summary["queued"] == 1
|
||||
assert user_db.get_request(row["id"])["status"] == "fulfilled"
|
||||
|
||||
def test_an_admins_synced_request_is_fulfilled_whatever_the_policy(self, user_db, monkeypatch):
|
||||
self._setup(monkeypatch, policy=self.APPROVAL)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
row = _pending_request(user_db, admin["id"])
|
||||
|
||||
summary = self._run(user_db, admin)
|
||||
|
||||
assert summary["queued"] == 1
|
||||
assert user_db.get_request(row["id"])["status"] == "fulfilled"
|
||||
|
||||
def test_a_blocked_content_type_is_left_untouched(self, user_db, monkeypatch):
|
||||
self._setup(
|
||||
monkeypatch,
|
||||
policy={"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "blocked"},
|
||||
)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
row = _pending_request(user_db, reader["id"])
|
||||
|
||||
summary = self._run(user_db, admin)
|
||||
|
||||
assert summary["awaiting_approval"] == 1
|
||||
assert user_db.get_request(row["id"]) == row
|
||||
|
||||
def test_only_the_sources_the_owner_may_download_from_are_searched(self, user_db, monkeypatch):
|
||||
searched: list[str] = []
|
||||
policy = {
|
||||
**self.DIRECT,
|
||||
"REQUEST_POLICY_RULES": [
|
||||
{"source": "first", "content_type": "audiobook", "mode": "request_release"}
|
||||
],
|
||||
}
|
||||
self._setup(monkeypatch, policy=policy, searched=searched)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
_pending_request(user_db, reader["id"])
|
||||
|
||||
summary = self._run(user_db, admin)
|
||||
|
||||
assert summary["queued"] == 1
|
||||
assert searched == ["second"]
|
||||
|
||||
def test_a_per_user_override_counts(self, user_db, monkeypatch):
|
||||
self._setup(monkeypatch, policy=self.APPROVAL)
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
user_db.set_user_settings(reader["id"], {"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download"})
|
||||
_pending_request(user_db, reader["id"])
|
||||
|
||||
assert self._run(user_db, admin)["queued"] == 1
|
||||
|
||||
def test_with_requests_off_the_owner_downloads_directly_so_the_pass_fulfils(
|
||||
self, user_db, monkeypatch
|
||||
):
|
||||
self._setup(monkeypatch, policy={**self.APPROVAL, "REQUESTS_ENABLED": False})
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
_pending_request(user_db, reader["id"])
|
||||
|
||||
assert self._run(user_db, admin)["queued"] == 1
|
||||
|
||||
def test_without_login_there_is_no_policy_to_apply(self, user_db, monkeypatch):
|
||||
self._setup(monkeypatch, policy=self.APPROVAL, auth_mode="none")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
_pending_request(user_db, reader["id"])
|
||||
|
||||
assert self._run(user_db, admin)["queued"] == 1
|
||||
|
||||
@pytest.mark.parametrize(("policy", "reopened"), [(APPROVAL, 0), (DIRECT, 1)])
|
||||
def test_a_failed_download_is_only_reopened_when_the_pass_could_fulfil_it(
|
||||
self, user_db, monkeypatch, policy, reopened
|
||||
):
|
||||
"""A failed download is reopened only when its owner's policy lets the pass fulfil it."""
|
||||
self._setup(monkeypatch, policy=policy)
|
||||
reader = user_db.create_user(username="reader", role="user")
|
||||
row = TestRetryAfterCooldown._request(user_db, reader["id"], provider_id="1")
|
||||
|
||||
assert (
|
||||
auto_download.reopen_stale_failures(
|
||||
user_db,
|
||||
cooldown_days=7,
|
||||
db_path=user_db._db_path,
|
||||
now=TestRetryAfterCooldown.NOW,
|
||||
)
|
||||
== reopened
|
||||
)
|
||||
expected = "pending" if reopened else "fulfilled"
|
||||
assert user_db.get_request(row["id"])["status"] == expected
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user