Compare commits

..
19 Commits
Author SHA1 Message Date
Alex 019d36b27e Rename Booklore to Grimmory 2 (#792) 2026-03-21 15:48:06 +00:00
Alex 698eb07e71 Rename Booklore to Grimmory (#791) 2026-03-21 15:38:38 +00:00
Alex 8f949a73d5 Remove audible provider (#778) 2026-03-18 18:29:13 +00:00
Alex 2c6f46fc88 Combined mode follow-up (#777) 2026-03-18 18:27:39 +00:00
Alex 3f90c3805f Use title+author query for whitelisted indexers (#774) 2026-03-16 19:52:43 +00:00
Alex cb093f61c6 Feature: Combined book+audiobook downloads (#773)
- Adds a combined search option in the search bar selector
- Choose both a book and audiobook file in a two-step release modal, and
download both simultaneously from a single search result.
- Works for requests. Request both a book+audiobook at once, or works
seamlessly with request policies that differ between book + audiobook
(E.g. automatically download the ebook portion, while the audiobook gets
sent as a request)
- Hidden for users who have book or audiobooks blocked. 

Closes #611
2026-03-16 18:34:46 +00:00
Alex b464d62672 Fix entrypoint gosu test write (#772)
- Let gosu run the full test write
- Delete stale compose

Fixes #771 - gosu pipe broken on Proxmox LXC installs
2026-03-15 18:04:41 +00:00
Alex f3f26488b1 Fix TS error (#770) 2026-03-15 10:27:03 +00:00
Alex fec9d31c8a Frontend improvements for Audible provider (#769)
- Added square artwork support
- Added dedicated length and narrator icons
- Added audiobook info to release and details modals
- Moved search options button to accommodate larger Audible search
fields
2026-03-15 10:17:37 +00:00
cadric 3295be82a7 Add Audible metadata provider via Audimeta (#762)
Closes #515

  ## Summary

This adds a new `audible` metadata provider backed by the Audimeta API.

  The provider supports:
  - Audible/Audimeta metadata lookup without authentication
- region selection (`us`, `ca`, `uk`, `au`, `fr`, `de`, `jp`, `it`,
`in`, `es`, `br`)
  - ASIN book lookup
  - ISBN lookup with fallback search
  - series suggestions and series-order browsing
- richer audiobook metadata such as narrators, runtime, rating,
subtitle, cover, publisher, and series info
- configurable Audimeta base URL, timeout, cache usage, default sort,
and unreleased filtering

  ## Notes

  A few Audimeta-specific integration details were needed:
- send a meaningful `User-Agent`, otherwise Audimeta rejects requests
with `403`
  - send the `cache` parameter in the format Audimeta expects
- use `keywords` for general search instead of `query`, which gave
poor/irrelevant results for title-style
  searches

  ## Validation

  Tested locally with:
  - `python -m py_compile shelfmark/metadata_providers/audible.py`
  - `python -m pytest tests/metadata/test_audible.py -v`
- `python -m pytest
tests/metadata/test_metadata_provider_capabilities.py -v`

  Also verified manually in a Podman test container:
  - searching for `Discount Dan` returns Audible title `B0DXLXRNGG`
  - book details and series metadata load correctly

  ## Scope

This PR intentionally keeps the change localized to the provider layer
and docs:
  - new Audible provider
  - provider registration
  - provider docs
  - generated environment variable docs
2026-03-15 10:09:24 +00:00
Alex fff0fd07a1 Fix stale activity dismiss handling (#768)
Fixes #764
2026-03-15 10:08:45 +00:00
Alexanddependabot[bot] 3f1a14843b Update to React 19 (#766)
Supersedes the React dependabot patches
- `react`
- `react-dom`
- `@types/react`
- `@types/react-dom`

Left out Vite / tooling updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 18:46:56 +00:00
Alexanddependabot[bot] 21a11b06b9 Dependency updates roll-up (#765)
Supersedes the dependabot PRs after testing: 
- `actions/attest-build-provenance`
- `docker/build-push-action`
- `actions/checkout`
- `seleniumbase`
- `docker/metadata-action`
- `actions/setup-node`
- `python:3.14-slim`
- `@types/node`
- `postcss`
- `react-router-dom`

Left out for now: 
- `node:25-alpine`
- `vite@8`
- `@vitejs/plugin-react@6`

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 18:41:02 +00:00
Alex 0d856a3ef5 CodeQL fixes (#763)
- Block SSRF in image cover proxy (validate URL scheme and reject
private IPs)
- Sanitize settings tab name to prevent path traversal
2026-03-13 17:47:34 +00:00
Alex ebf4312174 Repo spring cleaning (#746)
- Add CI workflow (pytest + frontend typecheck/tests) on PRs
- Add CodeQL static analysis for Python and JS/TS
- Add Dependabot for pip, npm, Docker, and GitHub Actions
- Tighten workflow permissions
2026-03-13 17:09:49 +00:00
Alex 685c35d552 Hardcover list separation + Browser download fix (#745)
- Added full Hardcover reading status types into the list selector
- Split reading status entries from dedicated lists
- Added option to disable the automatic removal of books when downloaded
from a Hardcover list
- Fixed browser download not firing when the completed state was
triggered in specific cases
2026-03-13 14:33:10 +00:00
Alex 3d72f9e258 Various requested small features (#741)
- Added torrent removal option
- Pass Prowlarr seedtimes to download clients (excluding rTorrent)
- Split default release source option by content type
- Split download to browser option by content type
- Add "hide links" option
2026-03-12 17:36:07 +00:00
Alex 7f79da11e6 Activity routes logging (#736)
- Added specific logging for activity routes errors
- Fixed scrollbars
2026-03-11 19:59:12 +00:00
Alex c59ea46540 Frontend update + Misc fixes (#735)
- Updated frontend CSS to Tailwind v4
- Reverted socket IO origin restriction
- Fixed search queries not persisting after auth redirect
- Move advanced search options to left UI selector
- Unlock IRC source to be used for audiobook content_type
- Tweaked security settings env var syncing to be prioritised
- Fix AA "all languages" query generation
- Added language-free AA query as second fallback in case of no results
- Testing moving SeleniumBase scratch files to /tmp via symlink
- Added enhanced logging for activity dismissals and other events
- Removed iFrame restrictions
2026-03-11 18:16:34 +00:00
145 changed files with 6437 additions and 3024 deletions
+1
View File
@@ -1,6 +1,7 @@
.git
.github
.vscode
.local
.mypy_cache
README_images
.gitignore
+29
View File
@@ -0,0 +1,29 @@
version: 2
updates:
# Python dependencies
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Frontend npm dependencies
- package-ecosystem: "npm"
directory: "/src/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Dockerfile base images
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
@@ -6,6 +6,8 @@ on:
tags:
- 'v*'
workflow_dispatch:
permissions: read-all
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
@@ -29,18 +31,18 @@ jobs:
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for ${{ matrix.target }} image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
tags: |
@@ -50,13 +52,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push ${{ matrix.target }} Docker image
id: push
uses: docker/build-push-action@v5
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -67,10 +69,10 @@ jobs:
RELEASE_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation for ${{ matrix.target }} image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -89,14 +91,14 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Create legacy aliases
run: |
+55
View File
@@ -0,0 +1,55 @@
name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.10"
cache: "pip"
- name: Install dependencies
run: |
pip install -r requirements-base.txt
pip install -r requirements-shelfmark.txt
pip install pytest
- name: Run tests
run: pytest tests/ -x --tb=short
frontend-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
working-directory: src/frontend
run: npm ci
- name: Typecheck
working-directory: src/frontend
run: npm run typecheck
- name: Unit tests
working-directory: src/frontend
run: npm run test:unit
+38
View File
@@ -0,0 +1,38 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
permissions:
contents: read
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -25,7 +25,7 @@ COPY src/frontend/ ./
RUN npm run build
# Use python-slim as the base image
FROM python:3.10-slim AS base
FROM python:3.14-slim AS base
# Add build argument for version
ARG BUILD_VERSION
+9 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -14,6 +14,7 @@ help:
@echo " install - Install frontend dependencies"
@echo " dev - Start development server"
@echo " build - Build frontend for production"
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " frontend-test - Run frontend unit tests"
@@ -41,6 +42,13 @@ build:
@echo "Building frontend for production..."
cd $(FRONTEND_DIR) && npm run build
# Build frontend and sync to frontend-dist for the running container to serve
build-serve: build
@echo "Syncing build to frontend-dist..."
@mkdir -p frontend-dist
rsync -a --delete $(FRONTEND_DIR)/dist/ frontend-dist/
@echo "Done. Hit the Flask backend (port 8084) to test the production build."
# Preview production build
preview:
@echo "Previewing production build..."
-39
View File
@@ -1,39 +0,0 @@
# Bypass testing - switch between dev build and v1.0.1
# Usage:
# Test dev build: docker compose -f docker-compose.bypass-test.yml up shelfmark-dev
# Test v1.0.1: docker compose -f docker-compose.bypass-test.yml up shelfmark-stable
# Pull latest dev: docker compose -f docker-compose.bypass-test.yml build shelfmark-dev
# Pull v1.0.1: docker compose -f docker-compose.bypass-test.yml pull shelfmark-stable
services:
# Dev image from registry
shelfmark-dev:
image: ghcr.io/calibrain/shelfmark:dev
container_name: shelfmark-bypass-dev
environment:
PUID: 1000
PGID: 1000
DEBUG: true
ports:
- 8084:8084
volumes:
- ./.local/bypass-test/config-dev:/config
- ./.local/bypass-test/books:/books
- ./.local/bypass-test/log-dev:/var/log/shelfmark
- ./.local/bypass-test/tmp:/tmp/shelfmark
# Stable v1.0.1 for comparison
shelfmark-stable:
image: ghcr.io/calibrain/shelfmark:1.0.1
container_name: shelfmark-bypass-stable
environment:
PUID: 1000
PGID: 1000
DEBUG: true
ports:
- 8085:8084
volumes:
- ./.local/bypass-test/config-stable:/config
- ./.local/bypass-test/books:/books
- ./.local/bypass-test/log-stable:/var/log/shelfmark
- ./.local/bypass-test/tmp:/tmp/shelfmark
+1
View File
@@ -20,5 +20,6 @@ services:
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro
- ./frontend-dist:/app/frontend-dist:ro
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+44 -11
View File
@@ -21,6 +21,7 @@ This document lists all configuration options that can be set via environment va
- [Hardcover](#metadata-providers-hardcover)
- [Open Library](#metadata-providers-open-library)
- [Google Books](#metadata-providers-google-books)
- [Direct Download](#direct-download)
- [Download Sources](#direct-download-download-sources)
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
@@ -186,9 +187,11 @@ Default language filter for searches.
|----------|-------------|------|---------|
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `direct` |
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
| `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` |
| `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ |
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal. | string (choice) | `direct_download` |
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. | string (choice) | `direct_download` |
| `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ |
<details>
<summary>Detailed descriptions</summary>
@@ -213,6 +216,15 @@ Default sort order for search results.
- **Default:** `relevance`
- **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced))
#### `SHOW_RELEASE_SOURCE_LINKS`
**Show Release Source Links**
Show clickable release-source links in release and details modals. Metadata provider links stay enabled.
- **Type:** boolean
- **Default:** `true`
#### `METADATA_PROVIDER`
**Book Metadata Provider**
@@ -221,7 +233,7 @@ Choose which metadata provider to use for book searches.
- **Type:** string (choice)
- **Default:** `openlibrary`
- **Options:** `""` (No providers enabled)
- **Options:** `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
#### `METADATA_PROVIDER_AUDIOBOOK`
@@ -231,17 +243,27 @@ Metadata provider for audiobook searches. Uses the book provider if not set.
- **Type:** string (choice)
- **Default:** _empty string_
- **Options:** `""` (Use book provider), `""` (No providers enabled)
- **Options:** `""` (Use book provider), `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
#### `DEFAULT_RELEASE_SOURCE`
**Default Release Source**
**Default Book Release Source**
The release source tab to open by default in the release modal.
The release source tab to open by default in the release modal for books.
- **Type:** string (choice)
- **Default:** `direct_download`
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr)
#### `DEFAULT_RELEASE_SOURCE_AUDIOBOOK`
**Default Audiobook Release Source**
The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.
- **Type:** string (choice)
- **Default:** _empty string_
- **Options:** `""` (Use book release source), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
</details>
@@ -278,7 +300,7 @@ The release source tab to open by default in the release modal.
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER` | Automatically download completed files to your browser. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
| `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. | number | `3` |
| `STATUS_TIMEOUT` | How long to keep completed/failed downloads in the queue display. | number | `3600` |
@@ -561,14 +583,14 @@ Automatically open the downloads sidebar when a new download is queued.
- **Type:** boolean
- **Default:** `false`
#### `DOWNLOAD_TO_BROWSER`
#### `DOWNLOAD_TO_BROWSER_CONTENT_TYPES`
**Download to Browser**
Automatically download completed files to your browser.
Automatically download completed files to your browser for the selected content types.
- **Type:** boolean
- **Default:** `false`
- **Type:** string (comma-separated)
- **Default:** _empty list_
#### `MAX_CONCURRENT_DOWNLOADS`
@@ -1249,6 +1271,7 @@ How long to keep cached search results before they expire.
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
| `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` |
@@ -1490,6 +1513,16 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
- **Type:** string
- **Default:** _none_
#### `PROWLARR_TORRENT_ACTION`
**Torrent Completion Action**
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
- **Type:** string (choice)
- **Default:** `keep`
- **Options:** `keep` (Keep), `remove` (Remove)
#### `PROWLARR_USENET_CLIENT`
**Usenet Client**
+24 -3
View File
@@ -6,6 +6,15 @@ Shelfmark can run behind a reverse proxy at the root path (recommended) or under
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
```nginx
server {
listen 443 ssl;
@@ -19,7 +28,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
}
}
```
@@ -53,7 +62,7 @@ location /shelfmark/ {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
@@ -133,7 +142,7 @@ location /shelfmark/ {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
@@ -142,6 +151,18 @@ location /shelfmark/ {
---
## Troubleshooting false network errors
If login, settings saves, or downloads appear to fail in the browser but the action still completes on the server, check your proxy headers first.
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
---
## Health checks
Health checks work at `/shelfmark/api/health` when using a subpath configuration.
+53 -7
View File
@@ -138,9 +138,7 @@ test_write() {
return 1
fi
if ! (
echo 0123456789_TEST | gosu "$USERNAME" env HOME=/app tee "$test_file" > /dev/null
); then
if ! gosu "$USERNAME" sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
echo "Failed to write test file in $folder as $USERNAME"
return 1
fi
@@ -202,14 +200,62 @@ change_ownership() {
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
}
ensure_tree_writable() {
local folder="$1"
make_writable "$folder"
if [ -d "$folder" ]; then
chmod -R u+rwX,g+rwX "$folder" || echo "Failed to relax permissions for ${folder}, continuing..."
fi
}
ensure_symlinked_dir() {
local link_path="$1"
local target_path="$2"
ensure_tree_writable "$target_path"
if [ -L "$link_path" ]; then
local current_target
current_target=$(readlink "$link_path" 2>/dev/null || echo "")
if [ "$current_target" = "$target_path" ]; then
echo "$link_path already points to $target_path"
return 0
fi
echo "Replacing symlink $link_path -> $current_target with $target_path"
rm -f "$link_path" || echo "Failed to replace symlink ${link_path}, continuing..."
elif [ -d "$link_path" ]; then
echo "Moving existing scratch files from $link_path to $target_path"
find "$link_path" -xdev -mindepth 1 -maxdepth 1 -exec mv -t "$target_path" {} + 2>/dev/null || true
ensure_tree_writable "$target_path"
if ! rmdir "$link_path" 2>/dev/null; then
echo "Could not replace $link_path with symlink, leaving existing directory in place"
ensure_tree_writable "$link_path"
return 0
fi
elif [ -e "$link_path" ]; then
echo "$link_path exists and is not a directory, leaving it in place"
return 0
fi
if [ ! -e "$link_path" ]; then
ln -s "$target_path" "$link_path" || echo "Failed to create symlink ${link_path}, continuing..."
fi
}
fix_misowned /app
fix_misowned /var/log/shelfmark
fix_misowned /tmp/shelfmark
# SeleniumBase (internal bypasser) writes a patched chromedriver binary (uc_driver)
# into its own drivers directory. Some NAS/docker setups can apply restrictive ACLs
# to extracted image layers that block non-root writes; ensure the runtime UID owns it.
# Keep SeleniumBase on its default /app-based paths, but redirect the scratch
# directories into /tmp so bypasser startup doesn't depend on image-layer writes.
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
ensure_symlinked_dir /app/downloaded_files /tmp/shelfmark/seleniumbase/downloaded_files
ensure_symlinked_dir /app/archived_files /tmp/shelfmark/seleniumbase/archived_files
# Keep SeleniumBase's bundled drivers directory writable as well for
# compatibility with legacy UC code paths that still probe bundled assets.
set +e
SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
set -e
@@ -217,7 +263,7 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
# If the driver already exists, ensure it's executable for the runtime user.
# If the legacy driver already exists, ensure it's executable for the runtime user.
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
fi
+3 -3
View File
@@ -10,7 +10,7 @@ Shelfmark is a self-hosted web interface for searching and downloading books and
- [Calibre](https://calibre-ebook.com/)
- [Calibre-Web](https://github.com/janeczku/calibre-web)
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
- [Booklore](https://github.com/booklore-app/booklore)
- [Grimmory](https://github.com/grimmory-tools/grimmory)
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
## ✨ Features
@@ -71,7 +71,7 @@ volumes:
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
```
> **Tip**: Point the download volume to your CWA or Booklore ingest folder for automatic import.
> **Tip**: Point the download volume to your CWA or Grimmory ingest folder for automatic import.
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
@@ -110,7 +110,7 @@ Some of the additional options available in Settings:
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
- **AudiobookBay** - Web scraping source for audiobook torrents (audiobooks only)
- **IRC** - Add details for IRC book sources and download directly from the UI
- **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Resilience** - Auto DNS rotation and mirror fallback when sources are unreachable. Custom proxy support (SOCK5 + HTTP/S), Tor routing.
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
+1 -1
View File
@@ -1,4 +1,4 @@
pyvirtualdisplay
pyautogui
seleniumbase==4.45.10
seleniumbase==4.47.3
python-xlib
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
WAIT_SECONDS="${WAIT_SECONDS:-5}"
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
cleanup() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
wait_for_startup() {
local name="$1"
local elapsed=0
while [ "$elapsed" -lt "$STARTUP_TIMEOUT_SECONDS" ]; do
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Container $name no longer exists" >&2
return 1
fi
if [ "$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)" != "running" ]; then
echo "Container $name exited before startup completed" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
fi
if docker exec "$name" sh -lc "id appuser >/dev/null 2>&1 && ps -eo comm,args | awk '\$1 == \"gunicorn\" && index(\$0, \"shelfmark.main:app\") { found=1 } END { exit(found ? 0 : 1) }'" >/dev/null 2>&1; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Timed out waiting for $name to finish startup" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
}
start_container() {
local name="$1"
local image="$2"
local pre_entrypoint_script="${3:-}"
cleanup "$name"
if [ -n "$pre_entrypoint_script" ]; then
docker run -d \
--name "$name" \
--entrypoint sh \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" \
-lc "$pre_entrypoint_script
exec /app/entrypoint.sh" >/dev/null
else
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" >/dev/null
sleep "$WAIT_SECONDS"
fi
wait_for_startup "$name"
}
run_probe() {
local name="$1"
local mode="${2:-default}"
docker exec -u appuser -e PROBE_MODE="$mode" "$name" sh -lc 'python3 - <<'"'"'PY'"'"'
import asyncio
import os
import shelfmark.bypass.internal_bypasser as ib
async def run_probe():
driver = None
probe_mode = os.environ.get("PROBE_MODE", "default")
if probe_mode == "proxy_auth" and hasattr(ib, "_get_proxy_string"):
ib._get_proxy_string = lambda _url: "user:pass@127.0.0.1:8888"
if hasattr(ib, "_create_cdp_browser"):
try:
driver = await ib._create_cdp_browser("https://example.com")
profile = getattr(getattr(driver, "config", None), "user_data_dir", "")
print(f"PROBE=OK mode={probe_mode} fn=_create_cdp_browser profile={profile}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_cdp_browser type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_close_cdp_driver"):
await ib._close_cdp_driver(driver)
return
if hasattr(ib, "_create_driver"):
try:
driver = await ib._create_driver()
print(f"PROBE=OK mode={probe_mode} fn=_create_driver driver_type={type(driver).__name__}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_driver type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_quit_driver"):
await ib._quit_driver(driver)
return
print(f"PROBE=ERR mode={probe_mode} fn=unknown type=RuntimeError msg=no supported startup function found")
asyncio.run(run_probe())
PY'
}
show_logs() {
local name="$1"
docker logs --tail 80 "$name" 2>&1 | tail -n 20
}
scenario_latest_baseline() {
local name="sb-lab-latest-baseline"
echo
echo "== latest baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name"
cleanup "$name"
}
scenario_latest_drivers_readonly() {
local name="sb-lab-latest-drivers"
echo
echo "== latest drivers readonly =="
start_container "$name" "$LATEST_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_baseline() {
local name="sb-lab-latest-proxy-baseline"
echo
echo "== latest proxy auth baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name" "proxy_auth"
cleanup "$name"
}
scenario_latest_downloads_readonly() {
local name="sb-lab-latest-downloads"
echo
echo "== latest downloaded_files readonly =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown -R root:root /app/downloaded_files &&
chmod -R a-w /app/downloaded_files &&
find /app/downloaded_files -maxdepth 2 -printf "%M %u:%g %p\n"
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_downloads_readonly() {
local name="sb-lab-latest-proxy-downloads"
echo
echo "== latest proxy auth with readonly downloaded_files =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown appuser:appuser /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chmod 0666 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown root:root /app/downloaded_files &&
chmod 0555 /app/downloaded_files &&
ls -ld /app/downloaded_files &&
ls -la /app/downloaded_files
'
run_probe "$name" "proxy_auth"
show_logs "$name"
cleanup "$name"
}
scenario_latest_bind_mount_readonly() {
local name="sb-lab-latest-bind-ro"
local bind_dir
bind_dir="$(mktemp -d /tmp/sb-lab-bind.XXXXXX)"
echo
echo "== latest readonly bind mount for downloaded_files =="
chmod 0555 "$bind_dir"
cleanup "$name"
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
--mount "type=bind,src=${bind_dir},target=/app/downloaded_files,readonly" \
"$LATEST_IMAGE" >/dev/null
wait_for_startup "$name"
run_probe "$name"
show_logs "$name"
cleanup "$name"
rm -rf "$bind_dir"
}
scenario_legacy_drivers_readonly() {
local name="sb-lab-legacy-drivers"
echo
echo "== legacy drivers readonly =="
start_container "$name" "$LEGACY_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
main() {
require_cmd docker
scenario_latest_baseline
scenario_latest_drivers_readonly
scenario_latest_proxy_auth_baseline
scenario_latest_downloads_readonly
scenario_latest_proxy_auth_downloads_readonly
scenario_latest_bind_mount_readonly
scenario_legacy_drivers_readonly
}
main "$@"
+43 -12
View File
@@ -3,6 +3,7 @@ import os
import random
import signal
import socket
import stat
import subprocess
import threading
import time
@@ -27,6 +28,9 @@ from shelfmark.download.network import get_proxies, get_ssl_verify
logger = setup_logger(__name__)
SELENIUMBASE_RUNTIME_ROOT = "/tmp/shelfmark/seleniumbase"
SELENIUMBASE_DOWNLOADS_DIR = os.path.join(SELENIUMBASE_RUNTIME_ROOT, "downloaded_files")
# Challenge detection indicators
CLOUDFLARE_INDICATORS = [
"just a moment",
@@ -50,6 +54,21 @@ DISPLAY = {
LOCKED = threading.Lock()
def _describe_runtime_path(path: str) -> str:
"""Return compact ownership/mode info for a runtime path."""
try:
link_target = ""
if os.path.islink(path):
link_target = f" -> {os.readlink(path)}"
st = os.stat(path)
mode = stat.S_IMODE(st.st_mode)
return f"{path}{link_target} exists uid={st.st_uid} gid={st.st_gid} mode={oct(mode)}"
except FileNotFoundError:
return f"{path} missing"
except Exception as e:
return f"{path} error={type(e).__name__}: {e}"
class _CdpWorker:
def __init__(self) -> None:
self._thread: Optional[threading.Thread] = None
@@ -771,18 +790,30 @@ async def _create_cdp_browser(url: str) -> Any:
logger.debug(f"Creating Pure CDP browser with args: {browser_args}")
logger.debug(f"Browser screen size: {screen_width}x{screen_height}")
driver = await cdp_driver.start_async(
headless=False,
headed=False,
xvfb=True,
xvfb_metrics=f"{display_width},{display_height}",
sandbox=False,
lang="en",
incognito=True,
ad_block=True,
proxy=proxy,
browser_args=browser_args,
)
try:
driver = await cdp_driver.start_async(
headless=False,
headed=False,
xvfb=True,
xvfb_metrics=f"{display_width},{display_height}",
sandbox=False,
lang="en",
incognito=True,
ad_block=True,
proxy=proxy,
browser_args=browser_args,
)
except Exception as e:
logger.warning(f"Pure CDP browser startup failed: {type(e).__name__}: {e}")
logger.warning(
"SeleniumBase runtime paths: "
f"cwd={os.getcwd()}; "
f"{_describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR)}; "
f"{_describe_runtime_path('/app/downloaded_files')}; "
f"{_describe_runtime_path('downloaded_files')}; "
f"{_describe_runtime_path('/tmp')}"
)
raise
try:
await driver.page.set_window_rect(0, 0, screen_width, screen_height)
+5 -5
View File
@@ -179,18 +179,18 @@ def test_booklore_connection(current_values: dict[str, Any] | None = None) -> di
password = _get_value("BOOKLORE_PASSWORD", "") or ""
if not base_url:
return {"success": False, "message": "Booklore URL is required"}
return {"success": False, "message": "Grimmory URL is required"}
if not username:
return {"success": False, "message": "Booklore username is required"}
return {"success": False, "message": "Grimmory username is required"}
if not password:
return {"success": False, "message": "Booklore password is required"}
return {"success": False, "message": "Grimmory password is required"}
try:
library_options, _ = _get_booklore_select_options(base_url, username, password)
message = "Connected to Booklore"
message = "Connected to Grimmory"
if library_options:
message = f"Connected to Booklore ({len(library_options)} libraries)"
message = f"Connected to Grimmory ({len(library_options)} libraries)"
return {"success": True, "message": message}
except BookloreError as exc:
+124 -21
View File
@@ -185,6 +185,23 @@ _AUDIOBOOK_FORMAT_OPTIONS = [
{"value": "rar", "label": "RAR"},
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
{
"value": "book",
"label": "Books",
"description": "Automatically download completed book files to this browser.",
},
{
"value": "audiobook",
"label": "Audiobooks",
"description": "Automatically download completed audiobook files to this browser.",
},
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS
}
def _get_metadata_provider_options():
"""Build metadata provider options dynamically from enabled providers only."""
@@ -210,17 +227,30 @@ def _get_metadata_provider_options_with_none():
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
def _get_release_source_options():
"""Build release source options dynamically from registered sources."""
def _get_release_source_options_for_content_type(content_type: str):
"""Build release source options dynamically for a specific content type."""
from shelfmark.release_sources import list_available_sources
return [
{"value": source["name"], "label": source["display_name"]}
for source in list_available_sources()
if source.get("can_be_default", True)
and content_type in source.get("supported_content_types", ["ebook", "audiobook"])
]
def _get_book_release_source_options():
"""Build default release source options for book searches."""
return _get_release_source_options_for_content_type("ebook")
def _get_audiobook_release_source_options():
"""Build default release source options for audiobook searches."""
return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type(
"audiobook"
)
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
@@ -355,7 +385,7 @@ def general_settings():
TextField(
key="CALIBRE_WEB_URL",
label="Library URL",
description="Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc).",
description="Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc).",
placeholder="http://calibre-web:8083",
),
TextField(
@@ -429,6 +459,23 @@ def search_mode_settings():
default="relevance",
show_when={"field": "SEARCH_MODE", "value": "direct"},
),
CheckboxField(
key="SHOW_RELEASE_SOURCE_LINKS",
label="Show Release Source Links",
description=(
"Show clickable release-source links in release and details modals. "
"Metadata provider links stay enabled."
),
default=True,
),
CheckboxField(
key="SHOW_COMBINED_SELECTOR",
label="Show Combined Download Selector",
description="Show the option to search for and download both a book and audiobook together.",
default=True,
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
HeadingField(
key="universal_mode_heading",
title="Universal Mode Settings",
@@ -453,15 +500,33 @@ def search_mode_settings():
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
SelectField(
key="METADATA_PROVIDER_COMBINED",
label="Combined Mode Metadata Provider",
description="Metadata provider for combined mode searches. Uses the book provider if not set.",
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
default="",
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
SelectField(
key="DEFAULT_RELEASE_SOURCE",
label="Default Release Source",
description="The release source tab to open by default in the release modal.",
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
label="Default Book Release Source",
description="The release source tab to open by default in the release modal for books.",
options=_get_book_release_source_options, # Callable - evaluated lazily to avoid circular imports
default="direct_download",
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
SelectField(
key="DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
label="Default Audiobook Release Source",
description="The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.",
options=_get_audiobook_release_source_options, # Callable - evaluated lazily to avoid circular imports
default="",
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
]
@@ -619,6 +684,41 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
effective: dict[str, Any] = dict(existing)
effective.update(values)
if "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" in effective:
raw_content_types = effective.get("DOWNLOAD_TO_BROWSER_CONTENT_TYPES")
if raw_content_types is None:
normalized_content_types: list[str] = []
elif isinstance(raw_content_types, list):
normalized_content_types = [
str(value).strip().lower()
for value in raw_content_types
if str(value).strip()
]
else:
return {
"error": True,
"message": "Download to Browser must be a list.",
"values": values,
}
deduped_content_types: list[str] = []
for content_type in normalized_content_types:
if content_type not in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES:
allowed = ", ".join(sorted(_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES))
return {
"error": True,
"message": (
"Download to Browser contains an unsupported content type "
f"'{content_type}'. Supported values: {allowed}"
),
"values": values,
}
if content_type not in deduped_content_types:
deduped_content_types.append(content_type)
values["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
effective["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
# Books: only validate templates when saving to a folder.
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
@@ -773,8 +873,8 @@ def download_settings():
},
{
"value": "booklore",
"label": "Booklore (API)",
"description": "Upload files directly to Booklore",
"label": "Grimmory (API)",
"description": "Upload files directly to Grimmory",
},
],
default="folder",
@@ -857,14 +957,14 @@ def download_settings():
),
HeadingField(
key="booklore_heading",
title="Booklore",
description="Upload books directly to Booklore via API. Audiobooks always use folder mode.",
title="Grimmory",
description="Upload books directly to Grimmory (Formerly Booklore) via API. Audiobooks always use folder mode.",
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
TextField(
key="BOOKLORE_HOST",
label="Booklore URL",
description="Base URL of your Booklore instance",
label="Grimmory URL",
description="Base URL of your Grimmory instance",
placeholder="http://booklore:6060",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
@@ -872,14 +972,14 @@ def download_settings():
TextField(
key="BOOKLORE_USERNAME",
label="Username",
description="Booklore account username",
description="Grimmory account username",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
PasswordField(
key="BOOKLORE_PASSWORD",
label="Password",
description="Booklore account password",
description="Grimmory account password",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
@@ -905,7 +1005,7 @@ def download_settings():
SelectField(
key="BOOKLORE_LIBRARY_ID",
label="Library",
description="Booklore library to upload into.",
description="Grimmory library to upload into.",
options=get_booklore_library_options,
required=True,
user_overridable=True,
@@ -917,7 +1017,7 @@ def download_settings():
SelectField(
key="BOOKLORE_PATH_ID",
label="Path",
description="Booklore library path for uploads.",
description="Grimmory library path for uploads.",
options=get_booklore_path_options,
required=True,
filter_by_field="BOOKLORE_LIBRARY_ID",
@@ -930,7 +1030,7 @@ def download_settings():
ActionButton(
key="test_booklore",
label="Test Connection",
description="Verify your Booklore configuration",
description="Verify your Grimmory configuration",
style="primary",
callback=test_booklore_connection,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
@@ -1106,11 +1206,14 @@ def download_settings():
description="Automatically open the downloads sidebar when a new download is queued.",
default=False,
),
CheckboxField(
key="DOWNLOAD_TO_BROWSER",
MultiSelectField(
key="DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
label="Download to Browser",
description="Automatically download completed files to your browser.",
default=False,
description="Automatically download completed files to your browser for the selected content types.",
options=_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS,
default=[],
variant="dropdown",
user_overridable=True,
),
NumberField(
key="MAX_CONCURRENT_DOWNLOADS",
+25 -6
View File
@@ -72,10 +72,12 @@ _SELF_SETTINGS_SECTION_OPTIONS = [
_SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS}
_SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS]
_SEARCH_MODE_VALUES = {"direct", "universal"}
_SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK"}
_SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "METADATA_PROVIDER_COMBINED"}
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"SEARCH_MODE",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_COMBINED_SELECTOR",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
@@ -117,6 +119,18 @@ def _get_request_source_options():
return options
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
"""Return registered release source names that support the requested content type."""
from shelfmark.release_sources import list_available_sources
valid_sources: set[str] = set()
for source in list_available_sources():
supported_types = source.get("supported_content_types", ["ebook", "audiobook"])
if content_type in supported_types:
valid_sources.add(source["name"])
return valid_sources
def _get_request_policy_rule_columns():
source_capabilities = get_source_content_type_capabilities()
content_type_options = []
@@ -189,19 +203,24 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
)
return normalized_value, None
if key == "DEFAULT_RELEASE_SOURCE":
if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}:
if normalized_value == "":
return "", None
from shelfmark.release_sources import list_available_sources
valid_sources = {source["name"] for source in list_available_sources()}
valid_sources = _get_valid_release_source_names_for_content_type(
"audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook"
)
if normalized_value not in valid_sources:
return (
value,
"DEFAULT_RELEASE_SOURCE must be a valid release source name or empty",
f"{key} must be a valid release source name or empty",
)
return normalized_value, None
if key == "SHOW_COMBINED_SELECTOR":
if isinstance(value, bool):
return value, None
return bool(value), None
return value, None
+428 -104
View File
@@ -27,12 +27,109 @@ from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _require_authenticated(resolve_auth_mode: Callable[[], str]):
def _normalize_log_field(value: Any) -> str:
if value is None:
return "-"
text = str(value).strip()
return text or "-"
def _log_activity_rejection(
action: str,
*,
status_code: int,
reason: str,
auth_mode: Any = None,
viewer_scope: Any = None,
item_type: Any = None,
item_key: Any = None,
item_count: int | None = None,
missing_item_keys: list[str] | None = None,
owner_user_id: Any = None,
final_status: Any = None,
request_id: Any = None,
) -> None:
parts = [
f"Activity {action} rejected",
f"status={status_code}",
f"reason={_normalize_log_field(reason)}",
f"method={request.method}",
f"path={request.path}",
f"user={_normalize_log_field(session.get('user_id'))}",
f"db_user_id={_normalize_log_field(session.get('db_user_id'))}",
f"is_admin={bool(session.get('is_admin', False))}",
]
if auth_mode is not None:
parts.append(f"auth_mode={_normalize_log_field(auth_mode)}")
if viewer_scope is not None:
parts.append(f"viewer_scope={_normalize_log_field(viewer_scope)}")
if item_type is not None:
parts.append(f"item_type={_normalize_log_field(item_type)}")
if item_key is not None:
parts.append(f"item_key={_normalize_log_field(item_key)}")
if item_count is not None:
parts.append(f"item_count={item_count}")
if missing_item_keys:
parts.append(f"missing_item_keys={','.join(missing_item_keys)}")
if owner_user_id is not None:
parts.append(f"owner_user_id={_normalize_log_field(owner_user_id)}")
if final_status is not None:
parts.append(f"final_status={_normalize_log_field(final_status)}")
if request_id is not None:
parts.append(f"request_id={_normalize_log_field(request_id)}")
logger.warning(" ".join(parts))
def _activity_error_response(
action: str,
*,
status_code: int,
error: str,
code: str | None = None,
auth_mode: Any = None,
viewer_scope: Any = None,
item_type: Any = None,
item_key: Any = None,
item_count: int | None = None,
missing_item_keys: list[str] | None = None,
owner_user_id: Any = None,
final_status: Any = None,
request_id: Any = None,
):
_log_activity_rejection(
action,
status_code=status_code,
reason=error,
auth_mode=auth_mode,
viewer_scope=viewer_scope,
item_type=item_type,
item_key=item_key,
item_count=item_count,
missing_item_keys=missing_item_keys,
owner_user_id=owner_user_id,
final_status=final_status,
request_id=request_id,
)
payload: dict[str, Any] = {"error": error}
if code:
payload["code"] = code
if missing_item_keys:
payload["missing_item_keys"] = missing_item_keys
return jsonify(payload), status_code
def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str):
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return None
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return _activity_error_response(
action,
status_code=401,
error="Unauthorized",
auth_mode=auth_mode,
)
return None
@@ -40,46 +137,42 @@ def _resolve_db_user_id(
require_in_auth_mode: bool = True,
*,
user_db: UserDB | None = None,
action: str | None = None,
auth_mode: str | None = None,
):
raw_db_user_id = session.get("db_user_id")
if raw_db_user_id is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
auth_mode=auth_mode,
)
try:
parsed_db_user_id = int(raw_db_user_id)
except (TypeError, ValueError):
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
auth_mode=auth_mode,
)
if parsed_db_user_id < 1:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
auth_mode=auth_mode,
)
if user_db is not None:
@@ -91,14 +184,12 @@ def _resolve_db_user_id(
if db_user is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
auth_mode=auth_mode,
)
return parsed_db_user_id, None
@@ -116,12 +207,14 @@ def _resolve_activity_actor(
*,
user_db: UserDB,
resolve_auth_mode: Callable[[], str],
action: str,
) -> tuple[_ActorContext | None, Any | None]:
"""Resolve acting user identity for activity mutations.
Returns (actor, error_response). On success actor is non-None.
"""
if resolve_auth_mode() == "none":
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return _ActorContext(
db_user_id=None,
is_no_auth=True,
@@ -130,7 +223,11 @@ def _resolve_activity_actor(
viewer_scope=NOAUTH_VIEWER_SCOPE,
), None
db_user_id, db_gate = _resolve_db_user_id(user_db=user_db)
db_user_id, db_gate = _resolve_db_user_id(
user_db=user_db,
action=action,
auth_mode=auth_mode,
)
if db_user_id is None:
return None, db_gate
@@ -155,28 +252,43 @@ def _activity_ws_room(actor: _ActorContext) -> str:
def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | None:
"""Return a 403 response if the actor doesn't own the item, else None."""
"""Return an error string if the actor doesn't own the item, else None."""
if actor.is_admin:
return None
owner_user_id = normalize_positive_int(row.get("user_id"))
if owner_user_id != actor.db_user_id:
return jsonify({"error": "Forbidden"}), 403
return "Forbidden"
return None
def _check_terminal_download(row: dict[str, Any]) -> Any | None:
final_status = str(row.get("final_status") or "").strip().lower()
if final_status not in VALID_TERMINAL_STATUSES:
return jsonify({"error": "Only terminal downloads can be dismissed"}), 409
return "Only terminal downloads can be dismissed"
return None
def _check_terminal_request(row: dict[str, Any]) -> Any | None:
if _request_terminal_status(row) is None:
return jsonify({"error": "Only terminal requests can be dismissed"}), 409
return "Only terminal requests can be dismissed"
return None
def _download_row_log_context(row: dict[str, Any]) -> dict[str, Any]:
return {
"owner_user_id": normalize_positive_int(row.get("user_id")),
"final_status": row.get("final_status"),
"request_id": normalize_positive_int(row.get("request_id")),
}
def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]:
return {
"owner_user_id": normalize_positive_int(row.get("user_id")),
"request_id": normalize_positive_int(row.get("id")),
}
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
if is_admin:
request_rows = user_db.list_requests()
@@ -199,6 +311,39 @@ def _parse_item_key(item_key: Any, prefix: str) -> str | None:
_ALL_BUCKET_KEYS = (*ACTIVE_QUEUE_STATUSES, *TERMINAL_QUEUE_STATUSES)
def _build_queue_index(queue_status: dict[str, dict[str, Any]]) -> dict[str, tuple[str, dict[str, Any]]]:
"""Index live queue entries by task id for fast activity lookups."""
queue_index: dict[str, tuple[str, dict[str, Any]]] = {}
for bucket_key in _ALL_BUCKET_KEYS:
bucket = queue_status.get(bucket_key)
if not isinstance(bucket, dict):
continue
for task_id, payload in bucket.items():
normalized_bucket_key = bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key)
queue_index[str(task_id)] = (normalized_bucket_key, payload)
return queue_index
def _effective_download_row_for_activity(
row: dict[str, Any],
*,
has_live_queue_entry: bool,
) -> dict[str, Any]:
"""Treat stale persisted active rows as interrupted failures for activity APIs."""
final_status = str(row.get("final_status") or "").strip().lower()
if final_status != ACTIVE_DOWNLOAD_STATUS or has_live_queue_entry:
return row
effective_row = dict(row)
effective_row["final_status"] = QueueStatus.ERROR.value
status_message = effective_row.get("status_message")
if not isinstance(status_message, str) or not status_message.strip():
effective_row["status_message"] = "Interrupted"
return effective_row
def _build_download_status_from_db(
*,
db_rows: list[dict[str, Any]],
@@ -211,15 +356,7 @@ def _build_download_status_from_db(
Stale active rows (no queue entry) are treated as interrupted errors.
"""
status: dict[str, dict[str, Any]] = {key: {} for key in _ALL_BUCKET_KEYS}
# Index queue items by task_id for fast lookup: task_id -> (bucket_key, payload)
queue_index: dict[str, tuple[str, dict[str, Any]]] = {}
for bucket_key in _ALL_BUCKET_KEYS:
bucket = queue_status.get(bucket_key)
if not isinstance(bucket, dict):
continue
for task_id, payload in bucket.items():
queue_index[str(task_id)] = (bucket_key, payload)
queue_index = _build_queue_index(queue_status)
for row in db_rows:
task_id = str(row.get("task_id") or "").strip()
@@ -234,9 +371,11 @@ def _build_download_status_from_db(
bucket_key, queue_payload = queue_entry
status[bucket_key][task_id] = queue_payload
else:
# Stale active row — no queue entry means it was interrupted
download_payload = DownloadHistoryService.to_download_payload(row)
download_payload["status_message"] = "Interrupted"
effective_row = _effective_download_row_for_activity(
row,
has_live_queue_entry=False,
)
download_payload = DownloadHistoryService.to_download_payload(effective_row)
status[QueueStatus.ERROR][task_id] = download_payload
elif final_status in VALID_TERMINAL_STATUSES:
download_payload = DownloadHistoryService.to_download_payload(row)
@@ -336,13 +475,14 @@ def register_activity_routes(
@app.route("/api/activity/snapshot", methods=["GET"])
def api_activity_snapshot():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="snapshot")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="snapshot",
)
if actor_error is not None:
return actor_error
@@ -404,20 +544,21 @@ def register_activity_routes(
@app.route("/api/activity/dismiss", methods=["POST"])
def api_activity_dismiss():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="dismiss",
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
return _activity_error_response("dismiss", status_code=400, error="Invalid payload")
item_type = str(data.get("item_type") or "").strip().lower()
item_key = data.get("item_key")
@@ -427,18 +568,57 @@ def register_activity_routes(
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "item_key must be in the format download:<task_id>"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_key must be in the format download:<task_id>",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=item_key,
)
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
return jsonify({"error": "Download not found"}), 404
return _activity_error_response(
"dismiss",
status_code=404,
error="Download not found",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
)
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope))
effective_existing = _effective_download_row_for_activity(
existing,
has_live_queue_entry=task_id in live_queue_index,
)
ownership_error = _check_item_ownership(actor, existing)
if ownership_error is not None:
return _activity_error_response(
"dismiss",
status_code=403,
error=ownership_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
**_download_row_log_context(effective_existing),
)
terminal_error = _check_terminal_download(effective_existing)
if terminal_error is not None:
return _activity_error_response(
"dismiss",
status_code=409,
error=terminal_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
**_download_row_log_context(effective_existing),
)
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
@@ -450,18 +630,53 @@ def register_activity_routes(
elif item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "item_key must be in the format request:<id>"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_key must be in the format request:<id>",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=item_key,
)
request_row = user_db.get_request(request_id)
if request_row is None:
return jsonify({"error": "Request not found"}), 404
return _activity_error_response(
"dismiss",
status_code=404,
error="Request not found",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
request_id=request_id,
)
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, request_row)
if ownership_error is not None:
return _activity_error_response(
"dismiss",
status_code=403,
error=ownership_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
**_request_row_log_context(request_row),
)
terminal_error = _check_terminal_request(request_row)
if terminal_error is not None:
return _activity_error_response(
"dismiss",
status_code=409,
error=terminal_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
**_request_row_log_context(request_row),
)
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
@@ -470,7 +685,15 @@ def register_activity_routes(
)
dismissal_item = {"item_type": "request", "item_key": f"request:{request_id}"}
else:
return jsonify({"error": "item_type must be one of: download, request"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_type must be one of: download, request",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type=item_type,
item_key=item_key,
)
room = _activity_ws_room(actor)
emit_ws_event(
@@ -488,30 +711,51 @@ def register_activity_routes(
@app.route("/api/activity/dismiss-many", methods=["POST"])
def api_activity_dismiss_many():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss_many")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="dismiss_many",
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="Invalid payload",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
)
items = data.get("items")
if not isinstance(items, list):
return jsonify({"error": "items must be an array"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="items must be an array",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
)
dismissal_items: list[dict[str, str]] = []
missing_item_keys: list[str] = []
live_queue_index: dict[str, tuple[str, dict[str, Any]]] | None = None
for item in items:
if not isinstance(item, dict):
return jsonify({"error": "items must contain objects"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="items must contain objects",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_count=len(items),
)
item_type = str(item.get("item_type") or "").strip().lower()
item_key = item.get("item_key")
@@ -519,48 +763,121 @@ def register_activity_routes(
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "download item_key must be in the format download:<task_id>"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="download item_key must be in the format download:<task_id>",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=item_key,
item_count=len(items),
)
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
missing_item_keys.append(f"download:{task_id}")
continue
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
if live_queue_index is None:
live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope))
effective_existing = _effective_download_row_for_activity(
existing,
has_live_queue_entry=task_id in live_queue_index,
)
ownership_error = _check_item_ownership(actor, existing)
if ownership_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=403,
error=ownership_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
item_count=len(items),
**_download_row_log_context(effective_existing),
)
terminal_error = _check_terminal_download(effective_existing)
if terminal_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=409,
error=terminal_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
item_count=len(items),
**_download_row_log_context(effective_existing),
)
dismissal_items.append({"item_type": "download", "item_key": f"download:{task_id}"})
continue
if item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "request item_key must be in the format request:<id>"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="request item_key must be in the format request:<id>",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=item_key,
item_count=len(items),
)
request_row = user_db.get_request(request_id)
if request_row is None:
missing_item_keys.append(f"request:{request_id}")
continue
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, request_row)
if ownership_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=403,
error=ownership_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
item_count=len(items),
**_request_row_log_context(request_row),
)
terminal_error = _check_terminal_request(request_row)
if terminal_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=409,
error=terminal_error,
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
item_count=len(items),
**_request_row_log_context(request_row),
)
dismissal_items.append({"item_type": "request", "item_key": f"request:{request_id}"})
continue
return jsonify({"error": "item_type must be one of: download, request"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="item_type must be one of: download, request",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_type=item_type,
item_key=item_key,
item_count=len(items),
)
if missing_item_keys:
return (
jsonify(
{
"error": "One or more activity items were not found",
"missing_item_keys": missing_item_keys,
}
),
404,
return _activity_error_response(
"dismiss_many",
status_code=404,
error="One or more activity items were not found",
auth_mode=resolve_auth_mode(),
viewer_scope=actor.viewer_scope,
item_count=len(items),
missing_item_keys=missing_item_keys,
)
dismissed_count = activity_view_state_service.dismiss_many(
@@ -583,13 +900,14 @@ def register_activity_routes(
@app.route("/api/activity/history", methods=["GET"])
def api_activity_history():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="history")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="history",
)
if actor_error is not None:
return actor_error
@@ -601,15 +919,16 @@ def register_activity_routes(
if offset is None:
offset = 0
if limit < 1:
return jsonify({"error": "limit must be a positive integer"}), 400
return _activity_error_response("history", status_code=400, error="limit must be a positive integer")
if offset < 0:
return jsonify({"error": "offset must be a non-negative integer"}), 400
return _activity_error_response("history", status_code=400, error="offset must be a non-negative integer")
history_rows = activity_view_state_service.list_history(
viewer_scope=actor.viewer_scope,
limit=limit,
offset=offset,
)
live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope))
payload: list[dict[str, Any]] = []
for history_row in history_rows:
@@ -634,9 +953,13 @@ def register_activity_routes(
if owner_user_id != actor.db_user_id:
raise RuntimeError(f"Viewer state out of scope for {item_key}")
effective_download_row = _effective_download_row_for_activity(
download_row,
has_live_queue_entry=task_id in live_queue_index,
)
payload.append(
DownloadHistoryService.to_history_row(
download_row,
effective_download_row,
dismissed_at=dismissed_at,
)
)
@@ -672,13 +995,14 @@ def register_activity_routes(
@app.route("/api/activity/history", methods=["DELETE"])
def api_activity_history_clear():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="history_clear")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="history_clear",
)
if actor_error is not None:
return actor_error
+29
View File
@@ -78,10 +78,39 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
}:
valid[key] = normalized_search_value
continue
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
if not isinstance(value, list):
errors.append(f"Invalid value for {key}: must be a list")
continue
candidate_values = [
str(entry).strip().lower()
for entry in value
if str(entry).strip()
]
normalized_values: list[str] = []
has_invalid_value = False
for entry in candidate_values:
if entry not in {"book", "audiobook"}:
errors.append(
f"Invalid value for {key}: unsupported content type '{entry}'"
)
has_invalid_value = True
continue
if entry not in normalized_values:
normalized_values.append(entry)
if has_invalid_value:
continue
valid[key] = normalized_values
continue
valid[key] = value
return valid, errors
+25 -3
View File
@@ -75,6 +75,30 @@ def determine_auth_mode(
return "none"
def _load_security_config() -> dict[str, Any]:
"""Load security settings with environment-backed values applied."""
from shelfmark.core.settings_registry import (
get_setting_value,
get_settings_field_map,
load_config_file,
)
try:
import shelfmark.config.security # noqa: F401
except Exception:
return load_config_file("security")
config = load_config_file("security")
field_map = get_settings_field_map(tab_name="security")
if not field_map:
return config
resolved = dict(config)
for key, (field, tab_name) in field_map.items():
resolved[key] = get_setting_value(field, tab_name)
return resolved
def load_active_auth_mode(
cwa_db_path: Any | None,
*,
@@ -82,9 +106,7 @@ def load_active_auth_mode(
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
from shelfmark.core.settings_registry import load_config_file
security_config = load_config_file("security")
security_config = _load_security_config()
return determine_auth_mode(
security_config,
cwa_db_path,
+2
View File
@@ -86,7 +86,9 @@ class Config:
# This handles cases where config is accessed before settings are registered
try:
import shelfmark.config.settings # noqa: F401 - main app settings
import shelfmark.config.security # noqa: F401 - security/auth settings
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
import shelfmark.config.users_settings # noqa: F401 - users/request settings
import shelfmark.release_sources # noqa: F401 - plugin settings
import shelfmark.metadata_providers # noqa: F401 - plugin settings
except ImportError:
+32
View File
@@ -1,12 +1,15 @@
"""Disk-based image cache with LRU eviction."""
import ipaddress
import json
import os
import socket
import threading
import time
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse
import requests
@@ -466,6 +469,32 @@ class ImageCacheService:
'hit_rate': round(hit_rate, 1),
}
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in ('http', 'https'):
return False
hostname = parsed.hostname
if not hostname:
return False
try:
resolved = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
except (socket.gaierror, ValueError):
return False
return True
def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]:
"""Fetch an image from URL and cache it.
@@ -477,6 +506,9 @@ class ImageCacheService:
Tuple of (image_data, content_type) or None on failure
"""
try:
if not self._is_safe_url(url):
logger.warning(f"Blocked request to disallowed URL: {url}")
return None
response = requests.get(
url,
+69 -3
View File
@@ -5,7 +5,7 @@ Business logic remains in oidc_auth.py.
"""
from typing import Any
from urllib.parse import quote
from urllib.parse import urlencode, urlsplit, urlunsplit
from authlib.jose.errors import InvalidClaimError
from authlib.integrations.flask_client import OAuth
@@ -23,6 +23,7 @@ from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
oauth = OAuth()
_RETURN_TO_SESSION_KEY = "oidc_return_to"
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
@@ -52,7 +53,67 @@ def _login_error_url(message: str) -> str:
"""Build a login URL (with script_root) that includes an OIDC error message."""
script_root = request.script_root.rstrip("/")
login_url = f"{script_root}/login" if script_root else "/login"
return f"{login_url}?oidc_error={quote(message)}"
params = {"oidc_error": message}
return_to = _get_pending_return_to()
if return_to and return_to != "/":
params["return_to"] = return_to
return f"{login_url}?{urlencode(params)}"
def _normalize_return_to(raw_return_to: Any) -> str | None:
"""Return a safe app-relative post-login target."""
if not isinstance(raw_return_to, str):
return None
value = raw_return_to.strip()
if not value or not value.startswith("/") or value.startswith("//"):
return None
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
return None
script_root = request.script_root.rstrip("/")
path = parsed.path or "/"
if script_root:
if path == script_root:
path = "/"
elif path.startswith(f"{script_root}/"):
path = path[len(script_root):] or "/"
if (
path == "/login"
or path.startswith("/login/")
or path == "/api"
or path.startswith("/api/")
):
return None
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
def _get_pending_return_to(*, clear: bool = False) -> str | None:
"""Read the pending post-login target from the session."""
raw_return_to = (
session.pop(_RETURN_TO_SESSION_KEY, None)
if clear
else session.get(_RETURN_TO_SESSION_KEY)
)
normalized = _normalize_return_to(raw_return_to)
if normalized is None and not clear:
session.pop(_RETURN_TO_SESSION_KEY, None)
return normalized
def _post_login_redirect_target(return_to: str | None) -> str:
"""Build the final redirect target, honoring script_root when present."""
normalized = _normalize_return_to(return_to) or "/"
script_root = request.script_root.rstrip("/")
if not script_root:
return normalized
if normalized == "/":
return f"{script_root}/"
return f"{script_root}{normalized}"
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
@@ -116,6 +177,11 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
"""Initiate OIDC login flow and redirect to the provider."""
try:
client, _ = _get_oidc_client()
return_to = _normalize_return_to(request.args.get("return_to"))
if return_to and return_to != "/":
session[_RETURN_TO_SESSION_KEY] = return_to
else:
session.pop(_RETURN_TO_SESSION_KEY, None)
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
return client.authorize_redirect(redirect_uri)
except ValueError:
@@ -213,7 +279,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
session.permanent = True
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
return redirect(request.script_root or "/")
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
except ValueError as e:
logger.error(f"OIDC callback error: {e}")
+226 -120
View File
@@ -22,6 +22,7 @@ from shelfmark.core.requests_service import (
RequestServiceError,
cancel_request,
create_request,
create_requests,
fulfil_request,
reject_request,
)
@@ -231,6 +232,158 @@ def _format_requester_label(user_db: UserDB, request_row: dict[str, Any]) -> str
return _format_user_label(None, user_id)
def _resolve_request_user_context(
user_db: UserDB,
*,
actor_user_id: int,
actor_username: str | None,
on_behalf_of_user_id: Any,
) -> tuple[int, str | None, str]:
if on_behalf_of_user_id in (None, ""):
actor_label = _format_user_label(actor_username, actor_user_id)
return actor_user_id, actor_username, actor_label
if not session.get("is_admin", False):
raise RequestServiceError("Admin required", status_code=403)
try:
target_user_id = int(on_behalf_of_user_id)
except (TypeError, ValueError) as exc:
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) from exc
if target_user_id <= 0:
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400)
target_user = user_db.get_user(user_id=target_user_id)
if not target_user:
raise RequestServiceError("User not found", status_code=404)
target_username = normalize_optional_text(target_user.get("username"))
actor_label = _format_user_label(actor_username, actor_user_id)
target_label = _format_user_label(target_username, target_user_id)
return target_user_id, target_username, f"{actor_label} on behalf of {target_label}"
def _prepare_request_create_arguments(
user_db: UserDB,
data: dict[str, Any],
) -> dict[str, Any]:
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
raise RequestServiceError(
"User identity is unavailable for request workflow",
status_code=403,
code="user_identity_unavailable",
)
actor_username = normalize_optional_text(session.get("user_id"))
target_user_id, _, actor_label = _resolve_request_user_context(
user_db,
actor_user_id=db_user_id,
actor_username=actor_username,
on_behalf_of_user_id=data.get("on_behalf_of_user_id"),
)
context = data.get("context") or {}
if not isinstance(context, dict):
raise RequestServiceError("context must be an object", status_code=400)
source = normalize_source(context.get("source"))
release_data = data.get("release_data")
request_level = context.get("request_level")
if request_level is None:
request_level = "book" if release_data is None else "release"
book_data = data.get("book_data")
if not isinstance(book_data, dict):
raise RequestServiceError("book_data must be an object", status_code=400)
request_title = _resolve_title_from_book_data(book_data)
content_type = normalize_content_type(
context.get("content_type")
or data.get("content_type")
or book_data.get("content_type")
)
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
book_data=book_data,
release_data=release_data,
content_type=content_type,
)
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
db_user_id=target_user_id,
)
if not requests_enabled:
raise RequestServiceError(
"Request workflow is disabled by policy",
status_code=403,
code="requests_unavailable",
)
max_pending = coerce_int(
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
default=20,
)
if max_pending < 1:
max_pending = 1
if max_pending > 1000:
max_pending = 1000
allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
note_value = data.get("note") if allow_notes else None
resolved_mode = resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
)
logger.debug(
"request create policy actor=%s target_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
session.get("user_id"),
target_user_id,
source,
content_type,
request_level,
resolved_mode.value,
)
if resolved_mode == PolicyMode.BLOCKED:
raise RequestServiceError(
"Requesting is blocked by policy",
status_code=403,
code="policy_blocked",
required_mode=PolicyMode.BLOCKED.value,
)
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
raise RequestServiceError(
"Policy requires book-level requests",
status_code=403,
code="policy_requires_request",
required_mode=PolicyMode.REQUEST_BOOK.value,
)
return {
"create_args": {
"user_id": target_user_id,
"source_hint": source,
"content_type": content_type,
"request_level": request_level,
"policy_mode": resolved_mode.value,
"book_data": book_data,
"release_data": release_data,
"note": note_value,
"max_pending_per_user": max_pending,
},
"actor_label": actor_label,
"request_title": request_title,
}
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
release_data = request_row.get("release_data")
if isinstance(release_data, dict):
@@ -385,130 +538,20 @@ def register_request_routes(
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
actor_username = normalize_optional_text(session.get("user_id"))
actor_label = _format_user_label(actor_username, db_user_id)
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "No data provided"}), 400
context = data.get("context") or {}
if not isinstance(context, dict):
return jsonify({"error": "context must be an object"}), 400
source = normalize_source(context.get("source"))
release_data = data.get("release_data")
request_level = context.get("request_level")
if request_level is None:
request_level = "book" if release_data is None else "release"
book_data = data.get("book_data")
if not isinstance(book_data, dict):
return jsonify({"error": "book_data must be an object"}), 400
request_title = _resolve_title_from_book_data(book_data)
content_type = normalize_content_type(
context.get("content_type")
or data.get("content_type")
or book_data.get("content_type")
)
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
book_data=book_data,
release_data=release_data,
content_type=content_type,
)
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
db_user_id=db_user_id,
)
if not requests_enabled:
logger.debug(
"Request not created for '%s' by %s: requests are disabled",
request_title,
actor_label,
)
return _error_response(
"Request workflow is disabled by policy",
403,
code="requests_unavailable",
)
max_pending = coerce_int(
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
default=20,
)
if max_pending < 1:
max_pending = 1
if max_pending > 1000:
max_pending = 1000
allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
note_value = data.get("note") if allow_notes else None
resolved_mode = resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
)
logger.debug(
"request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
session.get("user_id"),
db_user_id,
source,
content_type,
request_level,
resolved_mode.value,
)
if resolved_mode == PolicyMode.BLOCKED:
logger.debug(
"Request blocked by policy for '%s' by %s",
request_title,
actor_label,
)
return _error_response(
"Requesting is blocked by policy",
403,
code="policy_blocked",
required_mode=PolicyMode.BLOCKED.value,
)
if resolved_mode == PolicyMode.REQUEST_BOOK:
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if requested_level != "book":
logger.debug(
"Request not created for '%s' by %s: policy requires book-level requests",
request_title,
actor_label,
)
return _error_response(
"Policy requires book-level requests",
403,
code="policy_requires_request",
required_mode=PolicyMode.REQUEST_BOOK.value,
)
try:
created = create_request(
user_db,
user_id=db_user_id,
source_hint=source,
content_type=content_type,
request_level=request_level,
policy_mode=resolved_mode.value,
book_data=book_data,
release_data=release_data,
note=note_value,
max_pending_per_user=max_pending,
)
prepared = _prepare_request_create_arguments(user_db, data)
created = create_request(user_db, **prepared["create_args"])
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
return _error_response(
str(exc),
exc.status_code,
code=exc.code,
required_mode=exc.required_mode,
)
event_payload = {
"request_id": created["id"],
@@ -519,7 +562,7 @@ def register_request_routes(
"Request created #%s for '%s' by %s",
created["id"],
event_payload["title"],
actor_label,
prepared["actor_label"],
)
emit_ws_event(
ws_manager,
@@ -531,7 +574,7 @@ def register_request_routes(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{db_user_id}",
room=f"user_{created['user_id']}",
)
_notify_admin_for_request_event(
@@ -542,6 +585,69 @@ def register_request_routes(
return jsonify(created), 201
@app.route("/api/requests/batch", methods=["POST"])
def api_create_requests_batch():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "No data provided"}), 400
raw_requests = data.get("requests")
if not isinstance(raw_requests, list) or len(raw_requests) == 0:
return jsonify({"error": "requests must contain at least one request"}), 400
try:
prepared_requests = [
_prepare_request_create_arguments(user_db, raw_request)
for raw_request in raw_requests
]
created_rows = create_requests(
user_db,
requests=[prepared["create_args"] for prepared in prepared_requests],
)
except RequestServiceError as exc:
return _error_response(
str(exc),
exc.status_code,
code=exc.code,
required_mode=exc.required_mode,
)
for created, prepared in zip(created_rows, prepared_requests):
event_payload = {
"request_id": created["id"],
"status": created["status"],
"title": _resolve_request_title(created),
}
logger.info(
"Request created #%s for '%s' by %s",
created["id"],
event_payload["title"],
prepared["actor_label"],
)
emit_ws_event(
ws_manager,
event_name="new_request",
payload=event_payload,
room="admins",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{created['user_id']}",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_CREATED,
request_row=created,
)
return jsonify(created_rows), 201
@app.route("/api/requests", methods=["GET"])
def api_list_requests():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
+130 -25
View File
@@ -37,10 +37,12 @@ class RequestServiceError(ValueError):
*,
status_code: int = 400,
code: str | None = None,
required_mode: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.code = code
self.required_mode = required_mode
def _normalize_match_text(value: Any) -> str:
@@ -132,6 +134,45 @@ def _normalize_admin_note(admin_note: Any) -> str | None:
return admin_note.strip() or None
def _prepare_request_create(
*,
user_id: int,
source_hint: str | None,
content_type: Any,
request_level: Any,
policy_mode: Any,
book_data: Any,
release_data: Any = None,
note: Any = None,
) -> dict[str, Any]:
validated_book_data = _validate_book_data(book_data)
normalized_note = normalize_note(note)
normalized_content_type = normalize_content_type(
content_type or validated_book_data.get("content_type")
)
validated_book_data["content_type"] = normalized_content_type
try:
normalized_request_level = validate_request_level_payload(request_level, release_data)
normalized_policy_mode = normalize_policy_mode(policy_mode)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
_validate_json_blob_size("book_data", validated_book_data)
_validate_json_blob_size("release_data", release_data)
return {
"user_id": user_id,
"source_hint": source_hint,
"content_type": normalized_content_type,
"request_level": normalized_request_level,
"policy_mode": normalized_policy_mode,
"book_data": validated_book_data,
"release_data": release_data,
"note": normalized_note,
}
def sync_delivery_states_from_queue_status(
user_db: "UserDB",
*,
@@ -208,21 +249,16 @@ def create_request(
max_pending_per_user: int | None = None,
) -> dict[str, Any]:
"""Create a pending request after service-level validation."""
validated_book_data = _validate_book_data(book_data)
normalized_note = normalize_note(note)
normalized_content_type = normalize_content_type(
content_type or validated_book_data.get("content_type")
prepared_request = _prepare_request_create(
user_id=user_id,
source_hint=source_hint,
content_type=content_type,
request_level=request_level,
policy_mode=policy_mode,
book_data=book_data,
release_data=release_data,
note=note,
)
validated_book_data["content_type"] = normalized_content_type
try:
normalized_request_level = validate_request_level_payload(request_level, release_data)
normalized_policy_mode = normalize_policy_mode(policy_mode)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
_validate_json_blob_size("book_data", validated_book_data)
_validate_json_blob_size("release_data", release_data)
if max_pending_per_user is not None:
pending_count = user_db.count_user_pending_requests(user_id)
@@ -236,9 +272,9 @@ def create_request(
duplicate = _find_duplicate_pending_request(
user_db,
user_id=user_id,
title=_normalize_match_text(validated_book_data.get("title")),
author=_normalize_match_text(validated_book_data.get("author")),
content_type=normalized_content_type,
title=_normalize_match_text(prepared_request["book_data"].get("title")),
author=_normalize_match_text(prepared_request["book_data"].get("author")),
content_type=prepared_request["content_type"],
)
if duplicate is not None:
raise RequestServiceError(
@@ -248,16 +284,85 @@ def create_request(
)
try:
return user_db.create_request(
return user_db.create_request(**prepared_request)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
def create_requests(
user_db: "UserDB",
*,
requests: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Create multiple pending requests atomically after validation."""
if not isinstance(requests, list) or len(requests) == 0:
raise RequestServiceError("requests must contain at least one request", status_code=400)
prepared_requests: list[dict[str, Any]] = []
pending_counts_by_user: dict[int, int] = {}
seen_request_keys: set[tuple[int, str, str, str]] = set()
for request in requests:
if not isinstance(request, dict):
raise RequestServiceError("requests must contain objects", status_code=400)
user_id = int(request["user_id"])
prepared_request = _prepare_request_create(
user_id=user_id,
source_hint=source_hint,
content_type=normalized_content_type,
request_level=normalized_request_level,
policy_mode=normalized_policy_mode,
book_data=validated_book_data,
release_data=release_data,
note=normalized_note,
source_hint=request.get("source_hint"),
content_type=request.get("content_type"),
request_level=request.get("request_level"),
policy_mode=request.get("policy_mode"),
book_data=request.get("book_data"),
release_data=request.get("release_data"),
note=request.get("note"),
)
request_key = (
user_id,
_normalize_match_text(prepared_request["book_data"].get("title")),
_normalize_match_text(prepared_request["book_data"].get("author")),
prepared_request["content_type"],
)
if request_key in seen_request_keys:
raise RequestServiceError(
"Duplicate pending request exists for this title/author/content_type",
status_code=409,
code="duplicate_pending_request",
)
seen_request_keys.add(request_key)
max_pending_per_user = request.get("max_pending_per_user")
if max_pending_per_user is not None:
existing_pending = pending_counts_by_user.get(user_id)
if existing_pending is None:
existing_pending = user_db.count_user_pending_requests(user_id)
if existing_pending >= max_pending_per_user:
raise RequestServiceError(
"Maximum pending requests reached for this user",
status_code=409,
code="max_pending_reached",
)
pending_counts_by_user[user_id] = existing_pending + 1
duplicate = _find_duplicate_pending_request(
user_db,
user_id=user_id,
title=request_key[1],
author=request_key[2],
content_type=request_key[3],
)
if duplicate is not None:
raise RequestServiceError(
"Duplicate pending request exists for this title/author/content_type",
status_code=409,
code="duplicate_pending_request",
)
prepared_requests.append(prepared_request)
try:
return user_db.create_requests(prepared_requests)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
+57 -1
View File
@@ -348,7 +348,11 @@ def _get_config_file_path(tab_name: str) -> Path:
# Core settings tabs share the main settings.json file
if tab_name in ("general", "search_mode"):
return config_dir / "settings.json"
return config_dir / "plugins" / f"{tab_name}.json"
# Sanitize tab_name to prevent path traversal
safe_name = Path(tab_name).name
if not safe_name or safe_name != tab_name:
raise ValueError(f"Invalid tab name: {tab_name}")
return config_dir / "plugins" / f"{safe_name}.json"
def _ensure_config_dir(tab_name: str) -> None:
@@ -481,6 +485,7 @@ def sync_env_to_config() -> None:
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
migrate_legacy_settings()
migrate_download_to_browser_settings()
migrate_mirror_settings()
@@ -691,6 +696,57 @@ def migrate_legacy_settings() -> None:
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
def migrate_download_to_browser_settings() -> None:
"""Migrate the legacy download-to-browser toggle to content-type selection."""
downloads_config = load_config_file("downloads")
legacy_key = "DOWNLOAD_TO_BROWSER"
new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES"
config_path = _get_config_file_path("downloads")
legacy_value: Any = None
legacy_present = False
if legacy_key in downloads_config:
legacy_value = downloads_config.get(legacy_key)
legacy_present = True
elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ:
legacy_value = os.environ.get(legacy_key)
legacy_present = True
if not legacy_present and legacy_key not in downloads_config:
return
updated_downloads = dict(downloads_config)
changed = False
if new_key not in updated_downloads and legacy_present:
enabled = False
if isinstance(legacy_value, bool):
enabled = legacy_value
elif isinstance(legacy_value, str):
enabled = legacy_value.strip().lower() in {"true", "1", "yes", "on"}
else:
enabled = bool(legacy_value)
updated_downloads[new_key] = ["book", "audiobook"] if enabled else []
changed = True
if legacy_key in updated_downloads:
updated_downloads.pop(legacy_key, None)
changed = True
if not changed:
return
try:
_ensure_config_dir("downloads")
with open(config_path, "w") as f:
json.dump(updated_downloads, f, indent=2)
logger.info("Migrated download-to-browser setting to content-type selection")
except Exception as exc:
logger.error(f"Failed to migrate download-to-browser settings: {exc}")
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
return None # Actions and headings don't have values
+96 -45
View File
@@ -470,6 +470,72 @@ class UserDB:
payload[key] = None
return payload
def _insert_request(
self,
conn: sqlite3.Connection,
*,
user_id: int,
content_type: str,
request_level: str,
policy_mode: str,
book_data: Dict[str, Any],
release_data: Optional[Dict[str, Any]] = None,
status: str = RequestStatus.PENDING,
source_hint: Optional[str] = None,
note: Optional[str] = None,
admin_note: Optional[str] = None,
reviewed_by: Optional[int] = None,
reviewed_at: Optional[str] = None,
delivery_state: str = DELIVERY_STATE_NONE,
delivery_updated_at: Optional[str] = None,
) -> Dict[str, Any]:
cursor = conn.execute(
"""
INSERT INTO download_requests (
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
policy_mode,
book_data,
release_data,
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
policy_mode,
self._serialize_json(book_data, "book_data"),
self._serialize_json(release_data, "release_data"),
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at,
),
)
request_id = cursor.lastrowid
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
parsed = self._parse_request_row(row)
if parsed is None:
raise ValueError(f"Request {request_id} not found after creation")
return parsed
def create_request(
self,
*,
@@ -504,53 +570,38 @@ class UserDB:
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
INSERT INTO download_requests (
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
policy_mode,
book_data,
release_data,
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
normalized_status,
normalized_delivery_state,
source_hint,
content_type,
normalized_request_level,
normalized_policy_mode,
self._serialize_json(book_data, "book_data"),
self._serialize_json(release_data, "release_data"),
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at,
),
created = self._insert_request(
conn,
user_id=user_id,
content_type=content_type,
request_level=normalized_request_level,
policy_mode=normalized_policy_mode,
book_data=book_data,
release_data=release_data,
status=normalized_status,
source_hint=source_hint,
note=note,
admin_note=admin_note,
reviewed_by=reviewed_by,
reviewed_at=reviewed_at,
delivery_state=normalized_delivery_state,
delivery_updated_at=delivery_updated_at,
)
conn.commit()
request_id = cursor.lastrowid
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
parsed = self._parse_request_row(row)
if parsed is None:
raise ValueError(f"Request {request_id} not found after creation")
return parsed
return created
finally:
conn.close()
def create_requests(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Create multiple request rows atomically and return them in input order."""
with self._lock:
conn = self._connect()
try:
created: List[Dict[str, Any]] = []
for request in requests:
created.append(self._insert_request(conn, **request))
conn.commit()
return created
finally:
conn.close()
+24 -12
View File
@@ -38,6 +38,8 @@ class DownloadRequest:
protocol: str
release_name: str
expected_hash: Optional[str]
seeding_time_limit: Optional[int] = None # minutes
ratio_limit: Optional[float] = None
def _diagnose_path_issue(path: str) -> str:
@@ -140,20 +142,28 @@ class ExternalClientHandler(DownloadHandler, ABC):
return
client, download_id, protocol = client_ref
if protocol != "usenet":
return
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
if protocol == "usenet":
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
)
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
)
elif protocol == "torrent":
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
return
try:
client.remove(download_id, delete_files=False)
except Exception as e:
logger.warning(
f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}"
)
def _remove_usenet_download(
self,
@@ -553,6 +563,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
logger.error(f"Failed to add to {client.name}: {e}")
+9
View File
@@ -237,6 +237,15 @@ class DelugeClient(DownloadClient):
if self._download_dir:
options["download_location"] = self._download_dir
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
options["seed_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
options["stop_at_ratio"] = float(ratio_limit)
options["stop_at_ratio_enabled"] = True
if torrent_info.is_magnet:
magnet_url = torrent_info.magnet_url or url
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
+59 -27
View File
@@ -61,6 +61,23 @@ def _normalize_tags(raw_tags: object) -> list[str]:
return tags
def _normalize_add_result(raw_result: object) -> str:
"""Normalize qBittorrent add responses to a comparable string."""
if raw_result is None:
return ""
if isinstance(raw_result, bytes):
return raw_result.decode("utf-8", errors="replace").strip()
return str(raw_result).strip()
def _is_explicit_add_failure(raw_result: object) -> bool:
"""Detect add responses that clearly indicate failure."""
normalized = _normalize_add_result(raw_result).rstrip(".").lower()
return normalized in {"fail", "fails", "error", "errors"}
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
@@ -291,13 +308,16 @@ class QBittorrentClient(DownloadClient):
tags = self._tags
# Ensure category exists (may already exist, which is fine)
try:
self._client.torrents_create_category(name=category)
except Exception as e:
# Conflict409Error means category exists - that's expected
# Log other errors but continue since download may still work
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
if category:
try:
self._client.torrents_create_category(name=category)
except Exception as e:
# Conflict409Error means category exists - that's expected
# Log other errors but continue since download may still work
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(
f"Could not create category '{category}': {type(e).__name__}: {e}"
)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
expected_hash = torrent_info.info_hash
@@ -305,14 +325,23 @@ class QBittorrentClient(DownloadClient):
# Add the torrent - use file content if we have it, otherwise URL
add_kwargs = {
"category": category,
"rename": name,
}
if category:
add_kwargs["category"] = category
if self._download_dir:
add_kwargs["save_path"] = self._download_dir
if tags:
add_kwargs["tags"] = ",".join(tags)
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
add_kwargs["seeding_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
add_kwargs["ratio_limit"] = float(ratio_limit)
if torrent_data:
result = self._client.torrents_add(
torrent_files=torrent_data,
@@ -326,29 +355,32 @@ class QBittorrentClient(DownloadClient):
**add_kwargs,
)
logger.debug(f"qBittorrent add result: {result}")
result_text = _normalize_add_result(result)
logger.debug(f"qBittorrent add result: {result_text}")
if result == "Ok.":
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
# Wait for torrent to appear in client.
# Use `/torrents/properties?hash=` rather than relying on `torrents/info`
# listing being immediately consistent.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug(f"qBittorrent add_download: {error}")
if loaded:
logger.info(f"Added torrent: {expected_hash}")
return expected_hash.lower()
time.sleep(0.5)
if _is_explicit_add_failure(result):
raise Exception(f"Failed to add torrent: {result_text}")
# Client said Ok, trust it
logger.warning(f"Torrent not yet visible, returning expected hash")
return expected_hash
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
# the torrent becomes visible over trusting the response body alone.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug(f"qBittorrent add_download: {error}")
if loaded:
logger.info(f"Added torrent: {expected_hash}")
return expected_hash.lower()
time.sleep(0.5)
raise Exception(f"Failed to add torrent: {result}")
logger.warning(
"Torrent add was not confirmed within the visibility grace period "
f"(response={result_text or '<empty>'}), returning expected hash"
)
return expected_hash
except Exception as e:
logger.error(f"qBittorrent add failed: {e}")
raise
+11 -1
View File
@@ -610,7 +610,17 @@ def prowlarr_clients_settings():
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
# Note: Torrent client download path must be mounted identically in both containers.
# Torrents are always copied (not moved) to preserve seeding capability.
SelectField(
key="PROWLARR_TORRENT_ACTION",
label="Torrent Completion Action",
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
options=[
{"value": "keep", "label": "Keep"},
{"value": "remove", "label": "Remove"},
],
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
# --- Usenet Client Selection ---
HeadingField(
@@ -188,6 +188,22 @@ class TransmissionClient(DownloadClient):
torrent_hash = torrent.hashString.lower()
logger.info(f"Added torrent to Transmission: {torrent_hash}")
# Apply per-torrent seeding limits from indexer
seed_kwargs = {}
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
seed_kwargs["seed_idle_limit"] = int(seeding_time_limit)
seed_kwargs["seed_idle_mode"] = 1 # per-torrent
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
seed_kwargs["seed_ratio_limit"] = float(ratio_limit)
seed_kwargs["seed_ratio_mode"] = 1 # per-torrent
if seed_kwargs:
try:
self._client.change_torrent(ids=torrent_hash, **seed_kwargs)
except Exception as e:
logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}")
return torrent_hash
except Exception as e:
+41 -2
View File
@@ -88,6 +88,7 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
_VERIFY_IO_WAIT_SECONDS = 3.0
_PUBLISH_VERIFY_RETRY_SECONDS = 0.25
def _verify_transfer_size(
@@ -119,6 +120,44 @@ def _verify_transfer_size(
)
def _is_stale_handle_error(error: Exception) -> bool:
return isinstance(error, OSError) and error.errno == getattr(errno, "ESTALE", 116)
def _verify_published_file(
dest: Path,
expected_size: int,
action: str,
) -> None:
"""Best-effort verify after publishing a temp file into place.
The temp file was already verified before publish. Some NFS mounts can report
a transient stale handle immediately after `os.replace()` makes the final path
visible, so retry once and then trust the successful publish instead of
turning the handoff into a false failure.
"""
try:
_verify_transfer_size(dest, expected_size, action)
return
except OSError as error:
if not _is_stale_handle_error(error):
raise
time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS)
try:
_verify_transfer_size(dest, expected_size, action)
except OSError as retry_error:
if not _is_stale_handle_error(retry_error):
raise
logger.warning(
"Skipping post-publish verification for %s after stale handle on %s: %s",
action,
dest,
retry_error,
)
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
"""Write data to a file with atomic collision detection.
@@ -417,7 +456,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
continue
try:
_verify_transfer_size(try_path, expected_size, "move")
_verify_published_file(try_path, expected_size, "move")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
raise
@@ -594,7 +633,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
continue
try:
_verify_transfer_size(try_path, expected_size, "copy")
_verify_published_file(try_path, expected_size, "copy")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
raise
+26 -25
View File
@@ -24,6 +24,7 @@ BOOKLORE_SUPPORTED_EXTENSIONS = {".azw", ".azw3", ".cb7", ".cbr", ".cbz", ".epub
BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join(
ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS)
)
BOOKLORE_DISPLAY_NAME = "Grimmory"
class BookloreError(Exception):
@@ -67,11 +68,11 @@ def build_booklore_config(
password = values.get("BOOKLORE_PASSWORD", "") or ""
if not base_url:
raise BookloreError("Booklore URL is required")
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} URL is required")
if not username:
raise BookloreError("Booklore username is required")
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} username is required")
if not password:
raise BookloreError("Booklore password is required")
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} password is required")
destination = _parse_destination(
values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY)
@@ -97,8 +98,8 @@ def build_booklore_config(
library_id_val = values.get("BOOKLORE_LIBRARY_ID")
path_id_val = values.get("BOOKLORE_PATH_ID")
library_id = _parse_int(library_id_val, "Booklore library ID")
path_id = _parse_int(path_id_val, "Booklore path ID")
library_id = _parse_int(library_id_val, f"{BOOKLORE_DISPLAY_NAME} library ID")
path_id = _parse_int(path_id_val, f"{BOOKLORE_DISPLAY_NAME} path ID")
return BookloreConfig(
base_url=base_url.rstrip("/"),
@@ -119,28 +120,28 @@ def booklore_login(booklore_config: BookloreConfig) -> str:
try:
response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls)
except requests.exceptions.ConnectionError as exc:
raise BookloreError("Could not connect to Booklore") from exc
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
except requests.exceptions.Timeout as exc:
raise BookloreError("Booklore connection timed out") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} connection timed out") from exc
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore login failed: {exc}") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}") from exc
if response.status_code in {401, 403}:
raise BookloreError("Booklore authentication failed")
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} authentication failed")
try:
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
raise BookloreError(f"Booklore login failed ({response.status_code})") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})") from exc
try:
data = response.json()
except ValueError as exc:
raise BookloreError("Invalid Booklore login response") from exc
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} login response") from exc
token = data.get("accessToken")
if not token:
raise BookloreError("Booklore did not return an access token")
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} did not return an access token")
return token
@@ -153,12 +154,12 @@ def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list
response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Failed to fetch Booklore libraries: {exc}") from exc
raise BookloreError(f"Failed to fetch {BOOKLORE_DISPLAY_NAME} libraries: {exc}") from exc
try:
return response.json()
except ValueError as exc:
raise BookloreError("Invalid Booklore libraries response") from exc
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response") from exc
def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None:
@@ -189,13 +190,13 @@ def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path:
if message:
message = f": {message[:200]}"
status_code = response.status_code if response is not None else "unknown"
raise BookloreError(f"Booklore upload failed ({status_code}){message}") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed ({status_code}){message}") from exc
except requests.exceptions.ConnectionError as exc:
raise BookloreError("Could not connect to Booklore") from exc
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
except requests.exceptions.Timeout as exc:
raise BookloreError("Booklore upload timed out") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload timed out") from exc
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore upload failed: {exc}") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}") from exc
def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None:
@@ -206,7 +207,7 @@ def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> Non
response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore refresh failed: {exc}") from exc
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} refresh failed: {exc}") from exc
def _supports_booklore(task: DownloadTask) -> bool:
@@ -231,7 +232,7 @@ def _booklore_format_error(rejected_files: List[Path]) -> str:
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
return (
f"Booklore does not support {rejected_list}. "
f"{BOOKLORE_DISPLAY_NAME} does not support {rejected_list}. "
f"Supported formats: {BOOKLORE_SUPPORTED_FORMATS_LABEL}"
)
@@ -267,7 +268,7 @@ def _post_process_booklore(
status_callback("error", str(e))
return None
status_callback("resolving", "Preparing Booklore upload")
status_callback("resolving", f"Preparing {BOOKLORE_DISPLAY_NAME} upload")
stage_action = STAGE_NONE
if is_managed_workspace_path(temp_file):
@@ -314,7 +315,7 @@ def _post_process_booklore(
if cancel_flag.is_set():
logger.info("Task %s: cancelled during Booklore upload", task.task_id)
return None
status_callback("resolving", f"Uploading to Booklore ({index}/{len(prepared.files)})")
status_callback("resolving", f"Uploading to {BOOKLORE_DISPLAY_NAME} ({index}/{len(prepared.files)})")
booklore_upload_file(booklore_config, token, file_path)
if booklore_config.refresh_after_upload:
@@ -361,9 +362,9 @@ def _post_process_booklore(
if not maybe_run_custom_script(script_context, status_callback=status_callback):
return None
message = "Uploaded to Booklore"
message = f"Uploaded to {BOOKLORE_DISPLAY_NAME}"
if len(prepared.files) > 1:
message = f"Uploaded to Booklore ({len(prepared.files)} files)"
message = f"Uploaded to {BOOKLORE_DISPLAY_NAME} ({len(prepared.files)} files)"
status_callback("complete", message)
success = True
return f"booklore://{task.task_id}"
@@ -374,7 +375,7 @@ def _post_process_booklore(
return None
except Exception as e:
logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e)
status_callback("error", f"Booklore upload failed: {e}")
status_callback("error", f"{BOOKLORE_DISPLAY_NAME} upload failed: {e}")
return None
finally:
cleanup_output_staging(
+33 -6
View File
@@ -82,7 +82,7 @@ if BASE_PATH:
# We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true),
# so Socket.IO should always use gevent here.
async_mode = 'gevent'
socketio_cors_allowed_origins = "*" if DEBUG else None
socketio_cors_allowed_origins = "*"
# Initialize Flask-SocketIO with reverse proxy support
socketio_path = f"{BASE_PATH}/socket.io" if BASE_PATH else "/socket.io"
@@ -656,10 +656,9 @@ def proxy_auth_middleware():
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("X-Frame-Options", "DENY")
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:; frame-ancestors 'none'",
"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")
@@ -744,6 +743,11 @@ def index() -> Response:
"""
return _serve_index_html()
@app.route('/theme-init.js')
def theme_init_js() -> Response:
"""Serve the blocking theme-init script."""
return send_from_directory(FRONTEND_DIST, 'theme-init.js', mimetype='application/javascript')
@app.route('/logo.png')
def logo() -> Response:
"""
@@ -977,6 +981,11 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
"direct_download",
user_id=db_user_id,
)
default_release_source_audiobook = app_config.get(
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"",
user_id=db_user_id,
)
configured_metadata_provider = app_config.get(
"METADATA_PROVIDER",
"",
@@ -1003,9 +1012,17 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
"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),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False),
"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,
),
"settings_enabled": _is_config_dir_writable(),
"onboarding_complete": _get_onboarding_complete(),
# Default sort orders
@@ -1966,6 +1983,11 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]:
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"
@@ -1990,6 +2012,7 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]:
"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 Exception as e:
logger.error_trace(f"Metadata providers error: {e}")
@@ -2360,11 +2383,15 @@ def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Respo
return jsonify({"error": "selected must be a boolean"}), 400
result = prov.set_book_target_state(book_id, target, selected)
return jsonify({
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)
@app.route('/api/releases', methods=['GET'])
+2
View File
@@ -12,6 +12,8 @@ Metadata providers allow searching for books and retrieving detailed metadata (t
|----------|---------------|-------------|
| **Hardcover** | Yes (API key) | Modern book tracking platform with GraphQL API. Get your key at [hardcover.app/account/api](https://hardcover.app/account/api) |
| **Open Library** | No | Free, open-source library catalog from the Internet Archive. Rate limited to ~100 requests/minute |
| **Google Books** | Yes (API key) | Google's book database with broad coverage and a free API key option |
## Core Components
+13
View File
@@ -194,6 +194,9 @@ class BookMetadata:
search_title: Optional[str] = None # Cleaner title for search queries (provider-specific)
search_author: Optional[str] = None # Cleaner author for search queries (provider-specific)
# Cover aspect ratio hint for the frontend ("portrait" or "square")
cover_aspect: Optional[str] = None
# Provider-specific display fields for cards/lists
display_fields: List[DisplayField] = field(default_factory=list)
@@ -525,6 +528,15 @@ def get_configured_provider_name(
app_config.refresh()
if content_type == "combined":
combined_provider = app_config.get(
"METADATA_PROVIDER_COMBINED",
"",
user_id=user_id,
)
if combined_provider or not fallback_to_main:
return combined_provider
if content_type == "audiobook":
audiobook_provider = app_config.get(
"METADATA_PROVIDER_AUDIOBOOK",
@@ -655,3 +667,4 @@ try:
from shelfmark.metadata_providers import googlebooks # noqa: F401, E402
except ImportError:
pass # Google Books provider is optional
+59 -30
View File
@@ -104,7 +104,22 @@ query GetUserLists {
me {
id
username
want_to_read_books: user_books_aggregate(where: {status_id: {_eq: 1}}) {
want_to_read_count: user_books_aggregate(where: {status_id: {_eq: 1}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
currently_reading_count: user_books_aggregate(where: {status_id: {_eq: 2}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
read_count: user_books_aggregate(where: {status_id: {_eq: 3}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
did_not_finish_count: user_books_aggregate(where: {status_id: {_eq: 5}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
@@ -371,16 +386,17 @@ query GetSeriesBooks($seriesId: Int!) {
}
"""
HARDCOVER_WANT_TO_READ_STATUS_ID = 1
HARDCOVER_STATUS_PREFIX = "status:"
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {
1: "want-to-read",
2: "currently-reading",
3: "read",
5: "did-not-finish",
}
HARDCOVER_STATUSES: list[dict] = [
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
{"id": 2, "label": "Currently Reading", "slug": "currently-reading", "query_key": "currently_reading_count"},
{"id": 3, "label": "Read", "slug": "read", "query_key": "read_count"},
{"id": 5, "label": "Did Not Finish", "slug": "did-not-finish", "query_key": "did_not_finish_count"},
]
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {s["id"]: s["slug"] for s in HARDCOVER_STATUSES}
HARDCOVER_STATUS_GROUP = "Reading Status"
HARDCOVER_LIST_ID_PREFIX = "id:"
HARDCOVER_WRITABLE_TARGET_GROUPS = {"My Books", "My Lists"}
HARDCOVER_WRITABLE_TARGET_GROUPS = {HARDCOVER_STATUS_GROUP, "My Lists"}
@dataclass(frozen=True)
@@ -1539,26 +1555,27 @@ class HardcoverProvider(MetadataProvider):
except (TypeError, ValueError):
return name
want_to_read_count_data = me_data.get("want_to_read_books", {})
want_to_read_aggregate = (
want_to_read_count_data.get("aggregate", {})
if isinstance(want_to_read_count_data, dict)
else {}
)
want_to_read_count = (
want_to_read_aggregate.get("count")
if isinstance(want_to_read_aggregate, dict)
else None
)
want_to_read_value = f"{HARDCOVER_STATUS_PREFIX}{HARDCOVER_WANT_TO_READ_STATUS_ID}"
seen_values.add(want_to_read_value)
options.append(
{
"value": want_to_read_value,
"label": _format_label("Want to Read", want_to_read_count),
"group": "My Books",
}
)
for status in HARDCOVER_STATUSES:
count_data = me_data.get(status["query_key"], {})
aggregate = (
count_data.get("aggregate", {})
if isinstance(count_data, dict)
else {}
)
count = (
aggregate.get("count")
if isinstance(aggregate, dict)
else None
)
value = f"{HARDCOVER_STATUS_PREFIX}{status['id']}"
seen_values.add(value)
options.append(
{
"value": value,
"label": _format_label(status["label"], count),
"group": HARDCOVER_STATUS_GROUP,
}
)
for list_item in me_data.get("lists", []):
if not isinstance(list_item, dict):
@@ -1652,6 +1669,7 @@ class HardcoverProvider(MetadataProvider):
state = self._fetch_book_target_state(book_id_int)
status_ids_to_invalidate: set[int] = set()
list_ids_to_invalidate: set[int] = set()
deselected_target: Optional[str] = None
if selected_target.startswith(HARDCOVER_STATUS_PREFIX):
status_id = self._parse_prefixed_int(selected_target, "status target")
@@ -1660,6 +1678,8 @@ class HardcoverProvider(MetadataProvider):
if changed:
if previous_status_id is not None:
status_ids_to_invalidate.add(previous_status_id)
if selected and previous_status_id != status_id:
deselected_target = f"{HARDCOVER_STATUS_PREFIX}{previous_status_id}"
status_ids_to_invalidate.add(status_id)
elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX):
list_id = self._parse_prefixed_int(selected_target, "list target")
@@ -1676,7 +1696,10 @@ class HardcoverProvider(MetadataProvider):
list_ids=list_ids_to_invalidate,
)
return {"changed": changed}
result_data: Dict[str, Any] = {"changed": changed}
if deselected_target:
result_data["deselected_target"] = deselected_target
return result_data
@staticmethod
def _unwrap_me_data(result: Optional[Dict]) -> Dict:
@@ -2716,4 +2739,10 @@ def hardcover_settings():
description="Filter out books with a release year in the future",
default=False,
),
CheckboxField(
key="HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD",
label="Auto-Remove from List on Download",
description="Automatically remove a book from the active Hardcover list when you download it",
default=True,
),
]
+38 -2
View File
@@ -1,5 +1,6 @@
"""Direct download source - Anna's Archive/Libgen with fallback cascade."""
from dataclasses import replace
import itertools
import json
import re
@@ -166,7 +167,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]:
filters_query = ""
for value in filters.lang if filters.lang else config.BOOK_LANGUAGE or []:
for value in filters.lang or []:
if value and value != "all":
filters_query += f"&lang={quote(value)}"
@@ -1162,6 +1163,23 @@ class DirectDownloadSource(ReleaseSource):
return None
return get_aa_content_type_dir(task.content_type)
def _search_books_with_language_fallback(
self,
query: str,
filters: SearchFilters,
*,
search_label: str,
) -> List[BrowseRecord]:
"""Retry AA queries without a language filter when filtered search returns nothing."""
results = search_books(query, filters)
if results or not filters.lang:
return results
logger.debug(
f"No {search_label} results with langs={filters.lang}, retrying without language filter"
)
return search_books(query, replace(filters, lang=None))
def search(
self,
book: BookMetadata,
@@ -1191,7 +1209,7 @@ class DirectDownloadSource(ReleaseSource):
logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}")
filters = plan.source_filters or SearchFilters()
filters.lang = lang_filter if lang_filter is not None else (filters.lang or [])
results = search_books(query, filters)
results = self._search_books_with_language_fallback(query, filters, search_label="manual")
self._last_search_type = "manual" if query else "title_author"
return [_browse_record_to_release(record) for record in results]
@@ -1242,6 +1260,24 @@ class DirectDownloadSource(ReleaseSource):
except Exception as e:
logger.error(f"Search error: {e}")
if not all_results and any(langs for _, langs in searches):
logger.debug("No title+author results with language filter, retrying without language filter")
for title, _langs in searches:
query = f"{title} {author}".strip()
if not query:
continue
logger.debug(f"Searching direct_download: title_author='{query}', langs=[]")
try:
for bi in search_books(query, SearchFilters()):
if bi.id not in seen_ids:
seen_ids.add(bi.id)
all_results.append(bi)
except SearchUnavailable:
raise
except Exception as e:
logger.error(f"Search error: {e}")
logger.info(f"Found {len(all_results)} releases via title+author")
return [_browse_record_to_release(record) for record in all_results]
+1 -1
View File
@@ -1,6 +1,6 @@
"""IRC release source plugin.
Searches and downloads ebooks from IRC channels via DCC protocol.
Searches and downloads ebook and audiobook releases from IRC channels via DCC protocol.
Available when IRC server, channel, and nickname are configured in settings.
Based on OpenBooks (https://github.com/evan-buss/openbooks).
+16 -8
View File
@@ -4,7 +4,6 @@ Stores search results in CONFIG_DIR to survive container restarts.
IRC searches are slow and resource-intensive, so we cache aggressively.
"""
import hashlib
import json
import time
from dataclasses import asdict
@@ -14,6 +13,7 @@ from typing import Any, Dict, List, Optional
from shelfmark.config import env
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.release_sources import Release, ReleaseProtocol
logger = setup_logger(__name__)
@@ -28,9 +28,10 @@ DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60
_cache_lock = Lock()
def _generate_cache_key(provider: str, provider_id: str) -> str:
"""Generate a cache key from provider and provider_id."""
return f"{provider}:{provider_id}"
def _generate_cache_key(provider: str, provider_id: str, content_type: Optional[str] = None) -> str:
"""Generate a cache key from provider, provider_id, and content type."""
normalized_content_type = "audiobook" if check_audiobook(content_type) else "ebook"
return f"{provider}:{provider_id}:{normalized_content_type}"
def _load_cache() -> Dict[str, Any]:
@@ -74,6 +75,7 @@ def _dict_to_release(data: Dict[str, Any]) -> Release:
def get_cached_results(
provider: str,
provider_id: str,
content_type: Optional[str] = None,
ttl_seconds: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""
@@ -82,6 +84,7 @@ def get_cached_results(
Args:
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
provider_id: Book ID in the provider's system
content_type: Search content type for cache isolation
ttl_seconds: Cache TTL in seconds (from settings)
Returns:
@@ -99,7 +102,7 @@ def get_cached_results(
if ttl_seconds == 0:
ttl_seconds = float('inf')
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()
@@ -113,6 +116,7 @@ def get_cached_results(
age = time.time() - cached_at
if age > ttl_seconds:
title = entry.get("title", cache_key)
logger.debug(f"IRC cache expired for '{title}' (age: {age:.0f}s > TTL: {ttl_seconds}s)")
# Don't delete here - let cleanup handle it
return None
@@ -136,6 +140,7 @@ def cache_results(
provider_id: str,
title: str,
releases: List[Release],
content_type: Optional[str] = None,
online_servers: Optional[List[str]] = None
) -> None:
"""
@@ -146,9 +151,10 @@ def cache_results(
provider_id: Book ID in the provider's system
title: Book title (for logging/display)
releases: List of Release objects from search
content_type: Search content type for cache isolation
online_servers: List of online server nicks (optional)
"""
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()
@@ -159,6 +165,7 @@ def cache_results(
cache["entries"][cache_key] = {
"provider": provider,
"provider_id": provider_id,
"content_type": "audiobook" if check_audiobook(content_type) else "ebook",
"title": title,
"releases": [_release_to_dict(r) for r in releases],
"online_servers": list(online_servers) if online_servers else [],
@@ -169,18 +176,19 @@ def cache_results(
logger.info(f"Cached {len(releases)} IRC releases for '{title}'")
def invalidate_cache(provider: str, provider_id: str) -> bool:
def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str] = None) -> bool:
"""
Remove a specific entry from the cache.
Args:
provider: Metadata provider name
provider_id: Book ID in the provider's system
content_type: Search content type for cache isolation
Returns:
True if entry was found and removed
"""
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()
+2 -2
View File
@@ -1,6 +1,6 @@
"""IRC client implementation using raw sockets.
Minimal IRC client for ebook searches.
Minimal IRC client for Shelfmark release searches.
"""
import re
@@ -64,7 +64,7 @@ class IRCConnectionError(IRCError):
class IRCClient:
"""Minimal IRC client for per-request ebook searches."""
"""Minimal IRC client for per-request IRC release searches."""
def __init__(
self,
+2 -2
View File
@@ -1,6 +1,6 @@
"""IRC DCC download handler.
Handles downloading books via IRC DCC protocol.
Handles downloading IRC releases via DCC protocol.
"""
from pathlib import Path
@@ -29,7 +29,7 @@ class IRCDownloadHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Download a book via IRC DCC. task.task_id contains the IRC request string."""
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
download_request = task.task_id
logger.info(f"IRC download: {download_request[:60]}...")
+11 -8
View File
@@ -11,14 +11,13 @@ from typing import Optional
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import is_audiobook as check_audiobook
logger = setup_logger(__name__)
# All recognized formats for parsing IRC result lines.
# This comprehensive list is used to identify file extensions in results.
# User's configured formats are used separately for filtering.
# Note: IRC source currently only supports ebooks, but audiobook formats
# are included for future-proofing and format detection consistency.
# User-configured formats are used separately for filtering.
ALL_RECOGNIZED_FORMATS = {
# Ebook formats
'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx',
@@ -29,9 +28,13 @@ ALL_RECOGNIZED_FORMATS = {
}
def _get_supported_formats() -> set[str]:
"""Get user's configured supported formats from settings."""
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
def _get_supported_formats(content_type: Optional[str] = None) -> set[str]:
"""Get the supported formats for the requested content type."""
if check_audiobook(content_type):
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
else:
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
if isinstance(formats, str):
return {fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()}
return {fmt.lower() for fmt in formats}
@@ -140,10 +143,10 @@ def parse_result_line(line: str) -> Optional[SearchResult]:
return None
def parse_results_file(content: str) -> list[SearchResult]:
def parse_results_file(content: str, content_type: Optional[str] = None) -> list[SearchResult]:
"""Parse a search results file into SearchResult objects."""
results = []
supported = _get_supported_formats()
supported = _get_supported_formats(content_type)
for line in content.splitlines():
result = parse_result_line(line)
+1 -1
View File
@@ -39,7 +39,7 @@ def irc_settings():
key="heading",
title="IRC",
description=(
"Search and download books from IRC ebook channels. "
"Search and download ebook and audiobook releases from IRC channels. "
"This source connects via IRC and uses DCC for file transfers. "
"Configure the connection details below to enable IRC search. "
"Note: DCC requires direct TCP connections to arbitrary ports, "
+37 -11
View File
@@ -1,6 +1,6 @@
"""IRC release source plugin.
Searches IRC ebook channels for book releases.
Searches IRC channels for ebook and audiobook releases.
"""
import tempfile
@@ -66,11 +66,11 @@ def _enforce_rate_limit() -> None:
@register_source("irc")
class IRCReleaseSource(ReleaseSource):
"""Search IRC channels for book releases."""
"""Search IRC channels for ebook and audiobook releases."""
name = "irc"
display_name = "IRC"
supported_content_types = ["ebook"] # IRC only supports ebooks
supported_content_types = ["ebook", "audiobook"]
can_be_default = False # Exclude from default source options (requires deliberate selection)
def __init__(self):
@@ -141,7 +141,7 @@ class IRCReleaseSource(ReleaseSource):
# Check cache first (unless expand_search/refresh is requested)
if not expand_search:
cached = get_cached_results(book.provider, book.provider_id)
cached = get_cached_results(book.provider, book.provider_id, content_type=content_type)
if cached:
_emit_status("Using cached results", phase='complete')
self._online_servers = set(cached.get("online_servers", []))
@@ -199,7 +199,8 @@ class IRCReleaseSource(ReleaseSource):
book.provider_id,
book.title,
[],
list(self._online_servers) if self._online_servers else None
content_type=content_type,
online_servers=list(self._online_servers) if self._online_servers else None,
)
return []
@@ -219,8 +220,8 @@ class IRCReleaseSource(ReleaseSource):
connection_manager.release_connection(client)
# Convert to Release objects
results = parse_results_file(content)
releases = self._convert_to_releases(results)
results = parse_results_file(content, content_type=content_type)
releases = self._convert_to_releases(results, content_type=content_type)
# Cache results
cache_results(
@@ -228,7 +229,8 @@ class IRCReleaseSource(ReleaseSource):
book.provider_id,
book.title,
releases,
list(self._online_servers) if self._online_servers else None
content_type=content_type,
online_servers=list(self._online_servers) if self._online_servers else None,
)
return releases
@@ -263,7 +265,7 @@ class IRCReleaseSource(ReleaseSource):
return ' '.join(parts)
# Format priority for sorting (lower = higher priority)
FORMAT_PRIORITY = {
EBOOK_FORMAT_PRIORITY = {
'epub': 0,
'mobi': 1,
'azw3': 2,
@@ -283,10 +285,33 @@ class IRCReleaseSource(ReleaseSource):
'zip': 16,
}
def _convert_to_releases(self, results: List[SearchResult]) -> List[Release]:
AUDIOBOOK_FORMAT_PRIORITY = {
'm4b': 0,
'mp3': 1,
'm4a': 2,
'flac': 3,
'opus': 4,
'ogg': 5,
'aac': 6,
'wav': 7,
'wma': 8,
'rar': 9,
'zip': 10,
}
def _convert_to_releases(
self,
results: List[SearchResult],
content_type: str = "ebook",
) -> List[Release]:
"""Convert parsed results to Release objects, sorted by online/format/server."""
releases = []
online_servers = self._online_servers if self._online_servers else set()
format_priority_map = (
self.AUDIOBOOK_FORMAT_PRIORITY
if content_type == "audiobook"
else self.EBOOK_FORMAT_PRIORITY
)
for result in results:
release = Release(
@@ -298,6 +323,7 @@ class IRCReleaseSource(ReleaseSource):
size_bytes=self._parse_size(result.size) if result.size else None,
protocol=ReleaseProtocol.DCC,
indexer=f"IRC:{result.server}",
content_type=content_type,
extra={
"server": result.server,
"author": result.author,
@@ -311,7 +337,7 @@ class IRCReleaseSource(ReleaseSource):
server = release.extra.get("server", "")
is_online = server in online_servers
fmt = release.format.lower() if release.format else ""
format_priority = self.FORMAT_PRIORITY.get(fmt, 99)
format_priority = format_priority_map.get(fmt, 99)
return (
0 if is_online else 1, # Online first
format_priority, # Then by format
@@ -74,11 +74,19 @@ class ProwlarrHandler(ExternalClientHandler):
release_name = prowlarr_result.get("title") or task.title or "Unknown"
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
# Seed criteria from the indexer (Torznab attributes)
raw_seed_time = prowlarr_result.get("minimumSeedTime")
seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None
raw_ratio = prowlarr_result.get("minimumRatio")
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
return DownloadRequest(
url=download_url,
protocol=protocol,
release_name=release_name,
expected_hash=expected_hash,
seeding_time_limit=seeding_time_limit,
ratio_limit=ratio_limit,
)
def _on_download_complete(self, task: DownloadTask) -> None:
+33 -17
View File
@@ -7,6 +7,8 @@ from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.core.search_plan import ReleaseSearchVariant
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.metadata_providers import BookMetadata
@@ -562,13 +564,15 @@ class ProwlarrSource(ReleaseSource):
logger.warning("Prowlarr not configured - skipping search")
return []
queries = [v.title for v in plan.title_variants if v.title]
queries = [q for q in queries if q]
variants = [v for v in plan.title_variants if v.title]
if not queries and plan.isbn_candidates:
queries = list(plan.isbn_candidates)
if not variants and plan.isbn_candidates:
variants = [
ReleaseSearchVariant(title=isbn, author="", languages=None)
for isbn in plan.isbn_candidates
]
if not queries:
if not variants:
logger.warning("No search query available for book")
return []
@@ -601,13 +605,13 @@ class ProwlarrSource(ReleaseSource):
query_type = "title"
indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
if len(queries) == 1:
if len(variants) == 1:
logger.debug(
f"Searching Prowlarr: {query_type}='{queries[0]}', {indexer_desc}, categories={categories}"
f"Searching Prowlarr: {query_type}='{variants[0].title}', {indexer_desc}, categories={categories}"
)
else:
logger.debug(
f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}"
f"Searching Prowlarr: {query_type} ({len(variants)} variants), {indexer_desc}, categories={categories}"
)
# Identify indexers that should be enriched via Torznab/Newznab.
@@ -616,9 +620,17 @@ class ProwlarrSource(ReleaseSource):
if indexer_ids:
non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids]
def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
"""Search indexers with given categories, collecting results."""
def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]:
"""Search indexers with given categories, collecting results.
Args:
query: Query string for standard indexers (title only).
cats: Category filter list.
enriched_query: Optional query for enriched indexers (title + author).
Falls back to ``query`` when not provided.
"""
results = []
eq = enriched_query or query
# Search standard indexers via JSON endpoint.
if indexer_ids:
@@ -652,14 +664,15 @@ class ProwlarrSource(ReleaseSource):
logger.warning(f"Search failed for all indexers: {e}")
# Search enriched indexers via Torznab/Newznab for richer metadata.
# Use enriched_query (title + author) for better results on these indexers.
for indexer_id in enriched_indexer_ids:
raw = client.torznab_search(indexer_id=indexer_id, query=query, categories=cats, search_type="book")
raw = client.torznab_search(indexer_id=indexer_id, query=eq, categories=cats, search_type="book")
if raw:
results.extend(raw)
else:
# Fallback to JSON search for enriched indexers if Torznab fails.
try:
raw_fallback = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
raw_fallback = client.search(query=eq, indexer_ids=[indexer_id], categories=cats)
if raw_fallback:
results.extend(raw_fallback)
except Exception as e:
@@ -679,18 +692,21 @@ class ProwlarrSource(ReleaseSource):
seen_keys: set[str] = set()
all_results: List[dict] = []
for idx, query in enumerate(queries, start=1):
for idx, variant in enumerate(variants, start=1):
_check_timeout()
if len(queries) > 1:
logger.debug(f"Prowlarr query {idx}/{len(queries)}: '{query}'")
query = variant.title
enriched_query = variant.query # title + author
raw_results = search_indexers(query=query, cats=categories)
if len(variants) > 1:
logger.debug(f"Prowlarr query {idx}/{len(variants)}: '{query}'")
raw_results = search_indexers(query=query, cats=categories, enriched_query=enriched_query)
# Auto-expand: if no results with categories and auto-expand enabled, retry without
if not raw_results and categories and auto_expand_enabled:
_check_timeout()
logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search")
raw_results = search_indexers(query=query, cats=None)
raw_results = search_indexers(query=query, cats=None, enriched_query=enriched_query)
self.last_search_type = "expanded"
for r in raw_results:
+1 -19
View File
@@ -19,25 +19,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="logo.png" />
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash.
// CSS variables aren't available until the stylesheet loads, so set
// the background color directly on <html> to avoid a white flash.
(function() {
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
let theme = savedTheme;
if (savedTheme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.backgroundColor = theme === 'dark' ? '#121212' : '#f8f8f8';
// Add class to prevent transitions on initial load
document.documentElement.classList.add('preload');
})();
</script>
<script src="theme-init.js"></script>
</head>
<body>
<div id="root"></div>
+694 -1005
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -8,23 +8,24 @@
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js",
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/*.node.test.js",
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"@tailwindcss/vite": "^4.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.5.3",
"vite": "^7.3.1"
}
+1 -4
View File
@@ -1,6 +1,3 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {},
}
+22
View File
@@ -0,0 +1,22 @@
// Apply theme immediately before first paint to prevent flash.
// This runs as a blocking script before the app bundle loads.
(function() {
var saved = localStorage.getItem('preferred-theme') || 'auto';
var theme = saved === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: saved;
var dark = theme === 'dark';
var bg = dark ? '#121212' : '#f8f8f8';
var fg = dark ? '#fff' : '#333';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
var s = document.createElement('style');
s.id = 'theme-init';
s.textContent = 'html,body,#root{background:' + bg + ';color:' + fg + '}';
document.head.appendChild(s);
document.documentElement.classList.add('preload');
})();
+411 -78
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import {
Book,
Release,
@@ -24,9 +24,10 @@ import {
cancelDownload,
retryDownload,
getConfig,
getStatus,
getMetadataProviders,
getMetadataSearchConfig,
createRequest,
createRequests,
isApiResponseError,
updateSelfUser,
setBookTargetState,
@@ -61,6 +62,7 @@ import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { formatActingAsUserName } from './utils/actingAsUser';
import { withBasePath } from './utils/basePath';
import { buildLoginRedirectPath, getReturnToFromSearch } from './utils/authRedirect';
import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders';
import { getEffectiveMetadataSort } from './utils/metadataSort';
import {
@@ -76,9 +78,11 @@ import {
getRequestSuccessMessage,
toContentType,
} from './utils/requestPayload';
import { applyRequestNoteToPayload } from './utils/requestConfirmation';
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery';
import { getDynamicOptionGroup } from './components/shared/DynamicDropdown';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
@@ -88,16 +92,19 @@ import './styles.css';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
const getInitialContentType = (): ContentType => {
const getInitialContentType = (): { contentType: ContentType; combinedMode: boolean } => {
try {
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
if (saved === 'combined') {
return { contentType: 'ebook', combinedMode: true };
}
if (saved === 'ebook' || saved === 'audiobook') {
return saved;
return { contentType: saved, combinedMode: false };
}
} catch {
// localStorage may be unavailable in private browsing
}
return 'ebook';
return { contentType: 'ebook', combinedMode: false };
};
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
@@ -136,6 +143,17 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
return fallback;
};
const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE =
'Download queued, but the proxy interrupted the response. Status will refresh shortly.';
type CombinedSelectionState = {
phase: 'ebook' | 'audiobook';
ebookMode: RequestPolicyMode;
audiobookMode: RequestPolicyMode;
stagedEbook?: { book: Book; release: Release };
stagedAudiobook?: Release;
};
type PendingOnBehalfDownload =
| {
type: 'book';
@@ -148,9 +166,16 @@ type PendingOnBehalfDownload =
release: Release;
releaseContentType: ContentType;
actingAsUser: ActingAsUserSelection;
}
| {
type: 'combined';
book: Book;
combinedState: CombinedSelectionState;
actingAsUser: ActingAsUserSelection;
};
function App() {
const location = useLocation();
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
@@ -207,15 +232,8 @@ function App() {
}, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]);
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
useEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType]);
const initialContentTypePref = useMemo(() => getInitialContentType(), []);
const [contentType, setContentType] = useState<ContentType>(initialContentTypePref.contentType);
const {
policy: requestPolicy,
@@ -249,6 +267,7 @@ function App() {
useEffect(() => {
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
setContentType(allowedContentTypes[0]);
setCombinedMode(false);
}
}, [allowedContentTypes, contentType]);
@@ -411,6 +430,7 @@ function App() {
}, [setBooks]);
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState<CreateRequestPayload[]>([]);
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(null);
const [fulfillingRequest, setFulfillingRequest] = useState<{
@@ -426,6 +446,7 @@ function App() {
clearTracking();
setActiveQueryTarget('general');
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
setActingAsUser(null);
setPendingOnBehalfDownload(null);
setFulfillingRequest(null);
@@ -445,10 +466,33 @@ function App() {
// UI state
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
// Combined mode state (ebook + audiobook in one transaction)
const [combinedMode, setCombinedMode] = useState(initialContentTypePref.combinedMode);
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
// Persist content type + combined mode to localStorage
useEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType, combinedMode]);
// Clear combined state when combined mode is turned off
// (combinedModeAllowed guard is in a separate effect below, after effectiveSearchMode is declared)
useEffect(() => {
if (!combinedMode) {
setCombinedState(null);
}
}, [combinedMode]);
const [config, setConfig] = useState<AppConfig | null>(null);
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<string | null>(null);
const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState<string | null>(null);
const [activeMetadataConfig, setActiveMetadataConfig] = useState<MetadataSearchConfig | null>(null);
const [activeQueryTarget, setActiveQueryTarget] = useState<string>('general');
const [activeResultsSort, setActiveResultsSort] = useState('');
@@ -549,6 +593,16 @@ function App() {
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
if (!prev || Object.keys(prev).length === 0) return;
const autoDownloadContentTypes = Array.isArray(config?.download_to_browser_content_types)
? config.download_to_browser_content_types
: [];
const canAutoDownloadContentType = (contentType?: string): boolean => {
const contentTypeKey = String(contentType || '').trim().toLowerCase() === 'audiobook'
? 'audiobook'
: 'book';
return autoDownloadContentTypes.includes(contentTypeKey);
};
// Check for new items in queue
const prevQueued = prev.queued || {};
const currQueued = curr.queued || {};
@@ -574,18 +628,16 @@ function App() {
});
// Check for completed items
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
const prevResolvingIds = new Set(Object.keys(prev.resolving || {}));
const prevQueuedIds = new Set(Object.keys(prevQueued));
const prevComplete = prev.complete || {};
const currComplete = curr.complete || {};
Object.keys(currComplete).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevComplete[bookId]) {
const book = currComplete[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
// Auto-download to browser if enabled
if (config?.download_to_browser && book.download_path) {
if (book.download_path && canAutoDownloadContentType(book.content_type)) {
const link = document.createElement('a');
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
link.download = '';
@@ -604,9 +656,10 @@ function App() {
});
// Check for failed items
const prevError = prev.error || {};
const currError = curr.error || {};
Object.keys(currError).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevResolvingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevError[bookId]) {
const book = currError[bookId];
const errorMsg = book.status_message || 'Download failed';
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
@@ -629,11 +682,13 @@ function App() {
getConfig(),
getMetadataProviders(),
]);
const activeConfiguredProvider = getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider: metadataProviderState.configured_provider,
configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook,
});
const activeConfiguredProvider = combinedMode && metadataProviderState.configured_provider_combined
? metadataProviderState.configured_provider_combined
: getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider: metadataProviderState.configured_provider,
configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook,
});
let nextMetadataConfig: MetadataSearchConfig | null = null;
if (cfg.search_mode === 'universal') {
@@ -669,6 +724,7 @@ function App() {
setMetadataProviders(metadataProviderState.providers);
setConfiguredMetadataProvider(metadataProviderState.configured_provider);
setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook);
setConfiguredCombinedMetadataProvider(metadataProviderState.configured_provider_combined);
setActiveMetadataConfig(nextMetadataConfig);
// Show onboarding modal on first run (settings enabled but not completed yet)
@@ -700,7 +756,7 @@ function App() {
} catch (error) {
console.error('Failed to load config:', error);
}
}, [clearTracking, contentType, setAdvancedFilters, setBooks]);
}, [clearTracking, combinedMode, contentType, setAdvancedFilters, setBooks]);
// Fetch config when authenticated
useEffect(() => {
@@ -710,11 +766,32 @@ function App() {
}, [isAuthenticated, loadConfig]);
const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct';
const defaultMetadataProviderForContentType = getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider,
configuredAudiobookMetadataProvider,
});
// Combined mode requires universal mode, config enabled, and both content types accessible
const combinedModeAllowed = useMemo(() => {
if (effectiveSearchMode !== 'universal') return false;
if (config?.show_combined_selector === false) return false;
const ebookMode = getDefaultMode('ebook');
const audiobookMode = getDefaultMode('audiobook');
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
// Auto-disable combined mode if policy changes make it unavailable
// Skip while config is still loading to avoid resetting localStorage-restored state
useEffect(() => {
if (!config) return;
if (combinedMode && !combinedModeAllowed) {
setCombinedMode(false);
}
}, [config, combinedMode, combinedModeAllowed]);
const defaultMetadataProviderForContentType = combinedMode && configuredCombinedMetadataProvider
? configuredCombinedMetadataProvider
: getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider,
configuredAudiobookMetadataProvider,
});
const effectiveMetadataProvider = effectiveSearchMode === 'universal'
? (defaultMetadataProviderForContentType || null)
: null;
@@ -1017,10 +1094,10 @@ function App() {
}
};
const submitRequest = useCallback(
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
const submitRequests = useCallback(
async (payloads: CreateRequestPayload[], successMessage: string): Promise<boolean> => {
try {
await createRequest(payload);
await createRequests(payloads);
await refreshActivitySnapshot();
showToast(successMessage, 'success');
await refreshRequestPolicy({ force: true });
@@ -1037,19 +1114,41 @@ function App() {
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
);
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
setPendingRequestPayload(payload);
}, []);
const openRequestConfirmation = useCallback((
payload: CreateRequestPayload,
extraPayloads: CreateRequestPayload[] = [],
onBehalfOfUserId: number | undefined = actingAsUser?.id,
) => {
const applyOnBehalf = (requestPayload: CreateRequestPayload): CreateRequestPayload => {
if (typeof onBehalfOfUserId !== 'number') {
return requestPayload;
}
return {
...requestPayload,
on_behalf_of_user_id: onBehalfOfUserId,
};
};
setPendingRequestPayload(applyOnBehalf(payload));
setPendingRequestExtraPayloads(extraPayloads.map(applyOnBehalf));
}, [actingAsUser?.id]);
const handleConfirmRequest = useCallback(
async (payload: CreateRequestPayload): Promise<boolean> => {
const success = await submitRequest(payload, getRequestSuccessMessage(payload));
if (success) {
setPendingRequestPayload(null);
}
return success;
async (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]): Promise<boolean> => {
const requestPayloads = [payload, ...(extraPayloads ?? pendingRequestExtraPayloads)].map((requestPayload) =>
applyRequestNoteToPayload(requestPayload, payload.note ?? '', allowRequestNotes)
);
const success = await submitRequests(
requestPayloads,
requestPayloads.length === 1 ? getRequestSuccessMessage(requestPayloads[0]) : 'Requests submitted',
);
if (!success) return false;
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
return true;
},
[submitRequest]
[allowRequestNotes, pendingRequestExtraPayloads, submitRequests]
);
const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => {
@@ -1060,6 +1159,20 @@ function App() {
return getDefaultMode(contentType);
}, [getDefaultMode, contentType]);
const getCombinedSelectionPhases = useCallback(
(state: Pick<CombinedSelectionState, 'ebookMode' | 'audiobookMode'>): ContentType[] => {
const phases: ContentType[] = [];
if (state.ebookMode !== 'request_book') {
phases.push('ebook');
}
if (state.audiobookMode !== 'request_book') {
phases.push('audiobook');
}
return phases;
},
[]
);
const buildReleaseDownloadPayload = useCallback(
(book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => {
const isManual = book.provider === 'manual';
@@ -1098,18 +1211,19 @@ function App() {
metadataConfigRef.current = activeMetadataConfig;
const removeBookFromActiveList = useCallback((book: Book) => {
if (config?.hardcover_auto_remove_on_download === false) return;
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
// Only auto-remove from lists the user owns (My Books / My Lists)
// Only auto-remove from lists the user owns (Reading Status / My Lists)
const listField = metadataConfigRef.current?.search_fields.find(
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
);
if (listField && listField.type === 'DynamicSelectSearchField') {
const group = getDynamicOptionGroup(listField.options_endpoint, target);
if (group && group !== 'My Books' && group !== 'My Lists') return;
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
}
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
@@ -1124,14 +1238,16 @@ function App() {
showToast(`Removed from ${listName || 'list'}`, 'info');
}
}).catch(() => {});
}, [showToast]);
}, [config?.hardcover_auto_remove_on_download, showToast]);
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
const directContentType: ContentType = 'ebook';
const payload = buildReleaseDataFromDirectBook(book);
const requestStartedAtSeconds = Date.now() / 1000;
try {
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await downloadRelease(payload, onBehalfOfUserId);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
@@ -1146,7 +1262,7 @@ function App() {
code: isApiResponseError(error) ? error.code : null,
});
if (requiredMode === 'request_release') {
openRequestConfirmation(buildDirectRequestPayload(book));
openRequestConfirmation(buildDirectRequestPayload(book), [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1154,6 +1270,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, payload.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -1168,6 +1295,7 @@ function App() {
releaseContentType: ContentType,
onBehalfOfUserId?: number
): Promise<void> => {
const requestStartedAtSeconds = Date.now() / 1000;
try {
trackRelease(book.id, release.source_id);
await downloadRelease(
@@ -1198,7 +1326,7 @@ function App() {
content_type: normalizedContentType,
request_level: 'release',
},
});
}, [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1212,7 +1340,7 @@ function App() {
content_type: normalizedContentType,
request_level: 'book',
},
});
}, [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1220,6 +1348,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, release.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify release download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -1227,6 +1366,69 @@ function App() {
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease]
);
const executeCombinedAction = useCallback(
async (book: Book, selection: CombinedSelectionState, onBehalfOfUserId?: number): Promise<void> => {
const ebookRelease = selection.stagedEbook?.release;
const audiobookRelease = selection.stagedAudiobook;
const ebookMode = ebookRelease ? getSourceMode(ebookRelease.source, 'ebook') : selection.ebookMode;
const audiobookMode = audiobookRelease ? getSourceMode(audiobookRelease.source, 'audiobook') : selection.audiobookMode;
const buildRequestPayload = (
release: Release | undefined,
releaseContentType: ContentType,
mode: RequestPolicyMode,
): CreateRequestPayload => {
const payload = mode === 'request_release'
? {
book_data: buildMetadataBookRequestData(book, releaseContentType),
release_data: buildReleaseDataFromMetadataRelease(book, release!, releaseContentType),
context: {
source: release!.source,
content_type: releaseContentType,
request_level: 'release' as const,
},
}
: {
book_data: buildMetadataBookRequestData(book, releaseContentType),
release_data: null,
context: {
source: '*',
content_type: releaseContentType,
request_level: 'book' as const,
},
};
if (typeof onBehalfOfUserId !== 'number') {
return payload;
}
return {
...payload,
on_behalf_of_user_id: onBehalfOfUserId,
};
};
const requestPayloads: CreateRequestPayload[] = [];
if (ebookMode === 'download') {
await executeReleaseDownload(book, ebookRelease!, 'ebook', onBehalfOfUserId);
} else {
requestPayloads.push(buildRequestPayload(ebookRelease, 'ebook', ebookMode));
}
if (audiobookMode === 'download') {
await executeReleaseDownload(book, audiobookRelease!, 'audiobook', onBehalfOfUserId);
} else {
requestPayloads.push(buildRequestPayload(audiobookRelease, 'audiobook', audiobookMode));
}
if (requestPayloads.length > 0) {
openRequestConfirmation(requestPayloads[0], requestPayloads.slice(1), onBehalfOfUserId);
}
},
[executeReleaseDownload, getSourceMode, openRequestConfirmation]
);
const handleConfirmOnBehalfDownload = useCallback(async (): Promise<boolean> => {
if (!pendingOnBehalfDownload) {
return true;
@@ -1236,6 +1438,12 @@ function App() {
try {
if (pendingOnBehalfDownload.type === 'book') {
await executeBookDownload(pendingOnBehalfDownload.book, onBehalfOfUserId);
} else if (pendingOnBehalfDownload.type === 'combined') {
await executeCombinedAction(
pendingOnBehalfDownload.book,
pendingOnBehalfDownload.combinedState,
onBehalfOfUserId
);
} else {
await executeReleaseDownload(
pendingOnBehalfDownload.book,
@@ -1249,7 +1457,7 @@ function App() {
} catch {
return false;
}
}, [executeBookDownload, executeReleaseDownload, pendingOnBehalfDownload]);
}, [executeBookDownload, executeCombinedAction, executeReleaseDownload, pendingOnBehalfDownload]);
// Direct-mode action (download or release-level request based on policy).
const handleDownload = async (book: Book): Promise<void> => {
@@ -1371,22 +1579,52 @@ function App() {
return;
}
if (mode === 'request_book') {
policyTrace('universal.get:request_modal', {
bookId: book.id,
requestLevel: 'book',
contentType: normalizedContentType,
// Combined mode is only available when both default content types are accessible.
if (combinedMode) {
const latestPolicy2 = await refreshRequestPolicy({ force: true }).catch(() => null);
const effectiveIsAdmin2 = latestPolicy2 ? Boolean(latestPolicy2.is_admin) : requestRoleIsAdmin;
const ebookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'ebook');
const audiobookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'audiobook');
if (ebookMode === 'request_book' && audiobookMode === 'request_book') {
const ebookPayload: CreateRequestPayload = {
book_data: buildMetadataBookRequestData(book, 'ebook'),
release_data: null,
context: { source: '*', content_type: 'ebook', request_level: 'book' },
};
const audiobookPayload: CreateRequestPayload = {
book_data: buildMetadataBookRequestData(book, 'audiobook'),
release_data: null,
context: { source: '*', content_type: 'audiobook', request_level: 'book' },
};
openRequestConfirmation(ebookPayload, [audiobookPayload]);
return;
}
const selectionPhases = getCombinedSelectionPhases({ ebookMode, audiobookMode });
setCombinedState({
phase: selectionPhases[0],
ebookMode,
audiobookMode,
});
openRequestConfirmation({
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: null,
context: {
source: '*',
content_type: normalizedContentType,
request_level: 'book',
},
});
return;
} else {
if (mode === 'request_book') {
policyTrace('universal.get:request_modal', {
bookId: book.id,
requestLevel: 'book',
contentType: normalizedContentType,
});
openRequestConfirmation({
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: null,
context: {
source: '*',
content_type: normalizedContentType,
request_level: 'book',
},
});
return;
}
}
if (book.provider && book.provider_id) {
@@ -1483,6 +1721,53 @@ function App() {
return refreshRequestPolicy({ force: true });
}, [refreshRequestPolicy]);
// Combined mode callbacks
const handleCombinedNext = useCallback((release: Release) => {
if (!releaseBook || !combinedState) return;
const phases = getCombinedSelectionPhases(combinedState);
const nextPhase = phases[phases.indexOf(combinedState.phase) + 1];
setCombinedState({
...combinedState,
phase: nextPhase,
stagedEbook: { book: releaseBook, release },
});
}, [combinedState, getCombinedSelectionPhases, releaseBook]);
const handleCombinedBack = useCallback((audiobookRelease: Release | null) => {
setCombinedState((prev) => prev ? { ...prev, phase: 'ebook', stagedAudiobook: audiobookRelease ?? undefined } : null);
}, []);
const handleCombinedDownload = useCallback(async (release: Release) => {
if (!combinedState || !releaseBook) return;
const nextCombinedState: CombinedSelectionState = combinedState.phase === 'ebook'
? {
...combinedState,
stagedEbook: { book: releaseBook, release },
}
: {
...combinedState,
stagedAudiobook: release,
};
if (actingAsUser) {
setPendingOnBehalfDownload({
type: 'combined',
book: releaseBook,
combinedState: nextCombinedState,
actingAsUser,
});
setCombinedState(null);
setReleaseBook(null);
return;
}
await executeCombinedAction(releaseBook, nextCombinedState);
setCombinedState(null);
setReleaseBook(null);
}, [actingAsUser, combinedState, executeCombinedAction, releaseBook]);
const handleRequestCancel = useCallback(
async (requestId: number) => {
try {
@@ -1764,22 +2049,29 @@ function App() {
const handleSearchModeChange = useCallback((nextMode: SearchMode) => {
setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev);
if (nextMode !== 'universal') {
setCombinedMode(false);
}
updateSelfUser({ settings: { SEARCH_MODE: nextMode } })
.then(() => loadConfig('settings-saved'))
.catch((err) => console.error('Failed to save search mode:', err));
}, [loadConfig]);
const handleMetadataProviderChange = useCallback((provider: string) => {
if (contentType === 'audiobook') {
if (combinedMode) {
setConfiguredCombinedMetadataProvider(provider);
} else if (contentType === 'audiobook') {
setConfiguredAudiobookMetadataProvider(provider);
} else {
setConfiguredMetadataProvider(provider);
}
const key = contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER';
const key = combinedMode
? 'METADATA_PROVIDER_COMBINED'
: contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER';
updateSelfUser({ settings: { [key]: provider } })
.then(() => loadConfig('settings-saved'))
.catch((err) => console.error('Failed to save metadata provider:', err));
}, [contentType, loadConfig]);
}, [combinedMode, contentType, loadConfig]);
const buildCurrentSearchRequest = useCallback((sortOverride?: string) => {
const appliedSort = effectiveSearchMode === 'universal'
@@ -1992,7 +2284,15 @@ function App() {
const isBrowseFulfilMode = fulfillingRequest !== null;
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType;
const activeReleaseContentType = fulfillingRequest?.contentType ?? combinedState?.phase ?? contentType;
const combinedSelectionPhases = combinedState ? getCombinedSelectionPhases(combinedState) : [];
const combinedCurrentStep = combinedState ? combinedSelectionPhases.indexOf(combinedState.phase) + 1 : 0;
const combinedIsFinalStep = combinedState
? combinedSelectionPhases[combinedSelectionPhases.length - 1] === combinedState.phase
: false;
const combinedHasPreviousStep = combinedState
? combinedSelectionPhases.indexOf(combinedState.phase) > 0
: false;
const usePinnedMainScrollContainer = sidebarPinnedOpen;
const handleReleaseModalClose = useCallback(() => {
@@ -2000,15 +2300,18 @@ function App() {
setFulfillingRequest(null);
return;
}
setCombinedState(null);
setReleaseBook(null);
}, [isBrowseFulfilMode]);
const pendingOnBehalfTitle = pendingOnBehalfDownload
? pendingOnBehalfDownload.type === 'book'
? pendingOnBehalfDownload.book.title || 'Untitled'
: pendingOnBehalfDownload.release.title ||
pendingOnBehalfDownload.book.title ||
'Untitled'
: pendingOnBehalfDownload.type === 'combined'
? pendingOnBehalfDownload.book.title || 'Untitled'
: pendingOnBehalfDownload.release.title ||
pendingOnBehalfDownload.book.title ||
'Untitled'
: '';
const pendingOnBehalfUserName = pendingOnBehalfDownload
? formatActingAsUserName(pendingOnBehalfDownload.actingAsUser)
@@ -2055,12 +2358,15 @@ function App() {
onLogout={handleLogoutWithCleanup}
onSearch={handleSearchDispatch}
onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined}
isAdvancedActive={showAdvanced}
isLoading={isSearching}
onShowToast={showToast}
onRemoveToast={removeToast}
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={setActiveQueryTarget}
@@ -2082,7 +2388,7 @@ function App() {
bottom: 0,
left: 0,
right: '25rem',
zIndex: 40,
zIndex: 20,
}
: { paddingTop: `${headerHeight}px` }
}
@@ -2099,9 +2405,17 @@ function App() {
activeMetadataProvider={effectiveMetadataProvider}
onMetadataProviderChange={handleMetadataProviderChange}
contentType={contentType}
combinedMode={combinedMode}
isAdmin={requestRoleIsAdmin}
onClose={() => setShowAdvanced(false)}
/>
{!isInitialState && activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-4 sm:px-6 lg:px-8 pt-2 lg:ml-16">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<main
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
style={
@@ -2130,6 +2444,8 @@ function App() {
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
activeQueryField={activeQueryField}
searchMode={effectiveSearchMode}
onSearchModeChange={handleSearchModeChange}
@@ -2192,6 +2508,7 @@ function App() {
? getUniversalActionButtonState(selectedBook.id)
: getDirectActionButtonState(selectedBook.id)
}
showReleaseSourceLinks={config?.show_release_source_links !== false}
/>
)}
@@ -2215,19 +2532,33 @@ function App() {
bookLanguages={bookLanguages}
currentStatus={statusForButtonState}
defaultReleaseSource={config?.default_release_source}
defaultAudiobookReleaseSource={config?.default_release_source_audiobook}
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
showReleaseSourceLinks={config?.show_release_source_links !== false}
onShowToast={showToast}
combinedMode={combinedState ? {
phase: combinedState.phase,
stepLabel: `Step ${combinedCurrentStep} of ${combinedSelectionPhases.length} — Select ${combinedState.phase === 'ebook' ? 'book' : 'audiobook'}`,
ebookMode: combinedState.ebookMode,
audiobookMode: combinedState.audiobookMode,
stagedEbookRelease: combinedState.stagedEbook?.release ?? null,
stagedAudiobookRelease: combinedState.stagedAudiobook ?? null,
onNext: !combinedIsFinalStep ? handleCombinedNext : undefined,
onBack: combinedHasPreviousStep ? handleCombinedBack : undefined,
onDownload: combinedIsFinalStep ? handleCombinedDownload : undefined,
} : null}
/>
)}
{pendingRequestPayload && (
<RequestConfirmationModal
payload={pendingRequestPayload}
extraPayloads={pendingRequestExtraPayloads}
allowNotes={allowRequestNotes}
onConfirm={handleConfirmRequest}
onClose={() => setPendingRequestPayload(null)}
onClose={() => { setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); }}
/>
)}
@@ -2359,8 +2690,10 @@ function App() {
}
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
const postLoginPath = getReturnToFromSearch(location.search);
const loginRedirectPath = buildLoginRedirectPath(location);
const appElement = authRequired && !isAuthenticated ? (
<Navigate to="/login" replace />
<Navigate to={loginRedirectPath} replace />
) : (
mainAppContent
);
@@ -2371,7 +2704,7 @@ function App() {
path="/login"
element={
shouldRedirectFromLogin ? (
<Navigate to="/" replace />
<Navigate to={postLoginPath} replace />
) : (
<LoginPage
onLogin={handleLogin}
+50 -25
View File
@@ -27,7 +27,9 @@ interface AdvancedFiltersProps {
activeMetadataProvider?: string | null;
onMetadataProviderChange?: (provider: string) => void;
contentType?: ContentType;
combinedMode?: boolean;
isAdmin?: boolean;
onClose?: () => void;
}
const SEARCH_MODE_OPTIONS = [
@@ -49,7 +51,9 @@ export const AdvancedFilters = ({
activeMetadataProvider,
onMetadataProviderChange,
contentType = 'ebook',
combinedMode = false,
isAdmin = false,
onClose,
}: AdvancedFiltersProps) => {
const { lang, content, formats } = filters;
@@ -91,40 +95,61 @@ export const AdvancedFilters = ({
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
: 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
const settingsForm = (
<div className={wrapperClassName}>
{onClose && (
<div className="flex justify-end mb-1">
<button
type="button"
onClick={onClose}
className="p-1 rounded-full hover-action transition-colors"
aria-label="Close filters"
title="Close filters"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
style={{ color: 'var(--text-muted)' }}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{isAdmin && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<DropdownList
label="Search Mode"
options={SEARCH_MODE_OPTIONS}
value={searchMode}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
}}
placeholder="Choose a mode"
widthClassName="w-full"
/>
{searchMode === 'universal' && (
<DropdownList
label="Search Mode"
options={SEARCH_MODE_OPTIONS}
value={searchMode}
label={combinedMode ? 'Combined Metadata Provider' : contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
options={providerOptions}
value={activeMetadataProvider ?? ''}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
const next = Array.isArray(value) ? value[0] ?? '' : value;
onMetadataProviderChange?.(next);
}}
placeholder="Choose a mode"
placeholder="Choose a provider"
widthClassName="w-full"
/>
{searchMode === 'universal' && (
<DropdownList
label={contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
options={providerOptions}
value={activeMetadataProvider ?? ''}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? '' : value;
onMetadataProviderChange?.(next);
}}
placeholder="Choose a provider"
widthClassName="w-full"
/>
)}
</div>
</>
)}
</div>
)}
{searchMode === 'direct' && (
@@ -102,8 +102,8 @@ export const BookDownloadButton = ({
const baseClasses =
variant === 'icon'
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
@@ -169,7 +169,7 @@ export const BookGetButton = ({
if (isIconVariant) {
return (
<button
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -182,7 +182,7 @@ export const BookGetButton = ({
return (
<button
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
className={`inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -30,7 +30,7 @@ const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className={`${className} flex-shrink-0`}
className={`${className} shrink-0`}
>
<path
strokeLinecap="round"
@@ -51,14 +51,23 @@ const renderSummary = (selectedOptions: DropdownListOption[]) => {
);
};
const STATUS_PREFIX = 'status:';
const isStatusTarget = (value: string): boolean => value.startsWith(STATUS_PREFIX);
const updateOptionChecked = (
prev: BookTargetOption[],
target: string,
checked: boolean,
): BookTargetOption[] =>
prev.map((option) =>
option.value === target ? { ...option, checked } : option,
);
prev.map((option) => {
if (option.value === target) return { ...option, checked };
// Statuses are mutually exclusive — uncheck other statuses when one is selected
if (checked && isStatusTarget(target) && isStatusTarget(option.value)) {
return { ...option, checked: false };
}
return option;
});
export const BookTargetDropdown = ({
provider,
@@ -136,6 +145,7 @@ export const BookTargetDropdown = ({
value: option.value,
label: option.label,
description: option.description,
group: option.group,
disabled: !option.writable || pendingTargets.has(option.value),
}));
}, [isLoading, loadError, options, pendingTargets]);
@@ -176,6 +186,16 @@ export const BookTargetDropdown = ({
target: toggledTarget,
selected: result.selected,
});
// When a status was implicitly deselected, sync other instances
if (result.deselectedTarget) {
setOptions((prev) => updateOptionChecked(prev, result.deselectedTarget!, false));
emitBookTargetChange({
provider,
bookId,
target: result.deselectedTarget,
selected: false,
});
}
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
@@ -203,7 +223,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-hidden`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
@@ -217,7 +237,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-hidden ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
aria-label="Hardcover Lists"
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
>
@@ -232,7 +252,7 @@ export const BookTargetDropdown = ({
options={dropdownOptions}
value={selectedValues}
onChange={handleChange}
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
placeholder={isLoading ? 'Loading…' : 'Hardcover'}
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
panelClassName={variant !== 'default' ? 'w-56' : undefined}
@@ -65,7 +65,7 @@ export const ConfigSetupBanner = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border border-[var(--border-muted)] shadow-2xl
border border-(--border-muted) shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -82,13 +82,13 @@ export const ConfigSetupBanner = ({
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-5 py-4 border-b border-(--border-muted)">
<h2 className="text-lg font-semibold">
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
</h2>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}>
docker-compose.yml
</div>
@@ -139,22 +139,22 @@ export const ConfigSetupBanner = ({
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
<div className="px-5 py-4 border-t border-(--border-muted) flex justify-end gap-3">
{showContinueButton ? (
<>
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
<button
onClick={handleContinue}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Continue to Settings
</button>
@@ -163,8 +163,8 @@ export const ConfigSetupBanner = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Got it
</button>
+24 -33
View File
@@ -12,6 +12,7 @@ interface DetailsModalProps {
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
showReleaseSourceLinks?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
@@ -22,6 +23,7 @@ export const DetailsModal = ({
onFindDownloads,
onSearchSeries,
buttonState,
showReleaseSourceLinks = true,
onShowToast,
}: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
@@ -91,6 +93,7 @@ export const DetailsModal = ({
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
const isMetadata = isMetadataBook(book);
const showBookSourceLink = Boolean(book.source_url) && (isMetadata || showReleaseSourceLinks);
const metadataActionText =
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
? 'Find Downloads'
@@ -125,8 +128,11 @@ export const DetailsModal = ({
// Use provider display name from backend, fall back to capitalized provider name
const providerDisplay = book.provider_display_name
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
const isSquareCover = book.cover_aspect === 'square';
const artworkMaxHeight = 'calc(90vh - 220px)';
const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const artworkMaxWidth = isSquareCover
? 'min(45vw, 400px, calc(90vh - 220px))'
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const additionalInfo =
book.info && Object.keys(book.info).length > 0
? Object.entries(book.info).filter(([key]) => {
@@ -135,7 +141,7 @@ export const DetailsModal = ({
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm bg-[var(--bg-soft)] sm:bg-[var(--bg)]';
const infoCardClass = 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
@@ -147,13 +153,13 @@ export const DetailsModal = ({
}}
>
<div
className={`details-container w-full max-w-4xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4">
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
@@ -198,7 +204,7 @@ export const DetailsModal = ({
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
className="flex w-full items-center justify-center rounded-xl border border-dashed border-(--border-muted) bg-(--bg)/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
No cover
@@ -248,29 +254,15 @@ export const DetailsModal = ({
</p>
</div>
)}
</div>
{/* Other display fields (pages, editions) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.length > 0 && (
<div className="flex flex-wrap gap-4 text-sm">
{otherDisplayFields.map(field => (
<span key={field.label} className="flex items-center gap-1.5">
{field.icon === 'book' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
)}
{field.icon === 'editions' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" />
</svg>
)}
<span className="text-gray-500 dark:text-gray-400">{field.label}:</span>
<span className="text-gray-900 dark:text-gray-100">{field.value}</span>
</span>
))}
</div>
)}
{/* Other display fields (length, narrator, format, etc.) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.map(field => (
<div key={field.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{field.label}</p>
<p className={infoValueClass}>{field.value}</p>
</div>
))}
</div>
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (
@@ -299,7 +291,7 @@ export const DetailsModal = ({
onSearchSeries(book.series_name!, book.series_id);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
@@ -329,12 +321,11 @@ export const DetailsModal = ({
</div>
<footer
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Source link - shown for both Universal and Direct Download modes */}
{book.source_url && (
{showBookSourceLink && (
<a
href={book.source_url}
target="_blank"
@@ -352,7 +343,7 @@ export const DetailsModal = ({
</svg>
</a>
)}
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
<div className="flex w-full flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
{hasBookTargets && book.provider_id && (
<BookTargetDropdown
provider={book.provider!}
+2 -3
View File
@@ -184,9 +184,8 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
style={{
background: triggerChrome === 'minimal' ? 'transparent' : 'var(--bg-soft)',
color: 'var(--text)',
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
@@ -205,7 +204,7 @@ export const Dropdown = ({
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`h-4 w-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
+45 -31
View File
@@ -7,6 +7,7 @@ export interface DropdownListOption {
description?: string;
disabled?: boolean;
icon?: ReactNode;
group?: string;
}
interface DropdownListProps {
@@ -115,37 +116,50 @@ export const DropdownList = ({
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => (
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
))}
</div>
)}
{({ close }) => {
let lastGroup: string | undefined;
return (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => {
const showGroupHeader = option.group != null && option.group !== lastGroup;
if (option.group != null) lastGroup = option.group;
return (
<div key={option.value}>
{showGroupHeader && (
<div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
<button
type="button"
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
</div>
);
})}
</div>
);
}}
</Dropdown>
);
};
+1 -1
View File
@@ -39,7 +39,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
{truncatedBuild && ` (${truncatedBuild})`}
</span>
{debug && (
<span className="text-xs px-1.5 py-0.5 rounded opacity-60" style={{ background: 'var(--border-muted)' }}>
<span className="text-xs px-1.5 py-0.5 rounded-sm opacity-60" style={{ background: 'var(--border-muted)' }}>
Debug
</span>
)}
+25 -13
View File
@@ -22,6 +22,7 @@ interface HeaderProps {
onSearchChange?: (value: string | number | boolean, label?: string) => void;
onSearch?: () => void;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
@@ -41,6 +42,8 @@ interface HeaderProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
onQueryTargetChange?: (target: string) => void;
@@ -58,6 +61,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onSearchChange,
onSearch,
onAdvancedToggle,
isAdvancedActive = false,
isLoading = false,
onDownloadsClick,
onSettingsClick,
@@ -77,6 +81,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode,
onCombinedModeChange,
queryTargets = [],
activeQueryTarget = 'general',
onQueryTargetChange,
@@ -153,9 +159,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const saved = localStorage.getItem('preferred-theme') || 'auto';
applyTheme(saved);
// Remove preload class after initial theme is applied to enable transitions
// Remove preload class and inline theme-init styles now that the
// external CSS is loaded and React has mounted.
requestAnimationFrame(() => {
document.documentElement.classList.remove('preload');
document.getElementById('theme-init')?.remove();
});
}, []);
@@ -163,7 +171,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (localStorage.getItem('preferred-theme') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
const effective = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
}
};
mq.addEventListener('change', handler);
@@ -238,12 +248,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
}, [isDropdownOpen, isClosing]);
const applyTheme = (pref: string) => {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
const effective = pref === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
};
const handleLogout = () => {
@@ -372,7 +381,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-[var(--hover-action)]' : ''
isDropdownOpen ? 'bg-(--hover-action)' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
@@ -394,7 +403,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</svg>
{actingAsUser && (
<span
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-[var(--bg)]"
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-(--bg)"
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
/>
)}
@@ -620,7 +629,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
return (
<header
className="w-full sticky top-0 z-40 backdrop-blur-sm"
className="w-full sticky top-0 z-40 backdrop-blur-xs"
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
>
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4">
@@ -635,7 +644,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
className="h-10 w-10 shrink-0 cursor-pointer lg:hidden"
/>
)}
@@ -650,7 +659,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
className="hidden lg:block h-12 w-12 shrink-0 cursor-pointer"
/>
)}
<SearchBar
@@ -661,10 +670,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onChange={handleSearchChange}
onSubmit={handleHeaderSearch}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={isAdvancedActive}
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={onQueryTargetChange}
+8 -6
View File
@@ -2,6 +2,7 @@ import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
import { buildOidcLoginUrl } from '../utils/authRedirect';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -111,7 +112,7 @@ const PasswordLoginForm = ({
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full px-4 py-2.5 rounded-lg border focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -140,7 +141,7 @@ const PasswordLoginForm = ({
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
className="w-full px-4 py-2.5 rounded-lg border focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
@@ -168,7 +169,7 @@ const PasswordLoginForm = ({
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
className="w-4 h-4 rounded-sm focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
@@ -229,6 +230,7 @@ export const LoginForm = ({
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
const [searchParams] = useSearchParams();
const oidcError = searchParams.get('oidc_error');
const oidcLoginUrl = buildOidcLoginUrl(searchParams.toString());
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
@@ -240,9 +242,9 @@ export const LoginForm = ({
// Auto-redirect to OIDC provider when enabled and no errors present
useEffect(() => {
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
window.location.href = withBasePath('/api/auth/oidc/login');
window.location.href = oidcLoginUrl;
}
}, [oidcAutoRedirect, isOidc, error, oidcError]);
}, [oidcAutoRedirect, isOidc, error, oidcError, oidcLoginUrl]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -272,7 +274,7 @@ export const LoginForm = ({
{isOidc ? (
<>
<a
href={withBasePath('/api/auth/oidc/login')}
href={oidcLoginUrl}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white text-center transition-colors block bg-sky-700 hover:bg-sky-800"
>
{oidcButtonLabel || 'Sign in with OIDC'}
@@ -88,25 +88,25 @@ export const OnBehalfConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-lg rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-lg rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Download as {actingAsName}?
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close download confirmation"
disabled={isSubmitting}
>
@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({
<p className="text-sm opacity-90">
This download will use {actingAsName}&apos;s output preferences and destination settings.
</p>
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3">
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-3">
<p className="text-xs uppercase tracking-wide opacity-60">Title</p>
<p className="text-sm font-medium mt-1 break-words">{itemTitle}</p>
<p className="text-sm font-medium mt-1 wrap-break-word">{itemTitle}</p>
</div>
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
+12 -12
View File
@@ -331,7 +331,7 @@ export const OnboardingModal = ({
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" />
<div
className="relative rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
@@ -364,7 +364,7 @@ export const OnboardingModal = ({
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose} />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" onClick={handleClose} />
<div
className="relative rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
@@ -390,8 +390,8 @@ export const OnboardingModal = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -409,14 +409,14 @@ export const OnboardingModal = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl rounded-xl
border border-[var(--border-muted)] shadow-2xl
border border-(--border-muted) shadow-2xl
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
@@ -424,7 +424,7 @@ export const OnboardingModal = ({
aria-label="Setup Wizard"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-6 py-4 border-b border-(--border-muted)">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-sky-500/20 text-sky-500 text-sm font-medium">
{currentStepIndex + 1}
@@ -438,7 +438,7 @@ export const OnboardingModal = ({
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -455,7 +455,7 @@ export const OnboardingModal = ({
</div>
{/* Progress bar */}
<div className="h-1 bg-[var(--bg-soft)]">
<div className="h-1 bg-(--bg-soft)">
<div
className="h-full bg-sky-500 transition-all duration-300"
style={{ width: `${progress}%` }}
@@ -481,7 +481,7 @@ export const OnboardingModal = ({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-[var(--border-muted)] flex items-center justify-between h-[68px]">
<div className="px-6 py-4 border-t border-(--border-muted) flex items-center justify-between h-[68px]">
<div>
<button
onClick={handleSkip}
@@ -499,8 +499,8 @@ export const OnboardingModal = ({
onClick={handleBack}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
Back
+10 -10
View File
@@ -245,7 +245,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className="flex flex-col gap-1 max-w-xs">
{rows.map((row) => (
<div key={row.label} className="flex gap-2">
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{row.label}:</span>
<span className="text-gray-400 dark:text-gray-500 shrink-0">{row.label}:</span>
<span className="truncate">{row.value}</span>
</div>
))}
@@ -324,7 +324,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
{displayValue}
@@ -335,11 +335,11 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate gap-1.5`}>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
<span className="truncate">{displayValue}</span>
{peers && <span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{peers}</span>}
{peers && <span className="text-gray-400 dark:text-gray-500 shrink-0">{peers}</span>}
</div>
);
}
@@ -403,12 +403,12 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// Icon sized to match visual height of format text badges
const icon = isAudiobook ? (
// Headphones icon for audiobook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
) : (
// Book icon for ebook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
@@ -433,7 +433,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
if (!primaryFormat) {
return (
<div className="flex items-center justify-start" title={isAudiobook ? 'Audiobook' : 'Book'}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-[3.25rem]`}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-13`}>
{icon}
</span>
</div>
@@ -444,7 +444,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
const formatBadge = (
<span className="inline-flex items-center gap-1">
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-[3.25rem] text-center`}
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-13 text-center`}
>
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
</span>
@@ -492,7 +492,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
{displayValue}
@@ -506,7 +506,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
{isServerColumn && (
<span
className={`w-2 h-2 rounded-full mr-1.5 flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-2 h-2 rounded-full mr-1.5 shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
)}
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,15 @@ import {
interface RequestConfirmationModalProps {
payload: CreateRequestPayload | null;
extraPayloads?: CreateRequestPayload[];
allowNotes: boolean;
onConfirm: (payload: CreateRequestPayload) => Promise<boolean>;
onConfirm: (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]) => Promise<boolean>;
onClose: () => void;
}
export const RequestConfirmationModal = ({
payload,
extraPayloads = [],
allowNotes,
onConfirm,
onClose,
@@ -71,6 +73,10 @@ export const RequestConfirmationModal = ({
return payload ? buildRequestConfirmationPreview(payload) : null;
}, [payload]);
const extraPreviews = useMemo(() => {
return extraPayloads.map(buildRequestConfirmationPreview);
}, [extraPayloads]);
const [enriched, setEnriched] = useState<RequestConfirmationPreview | null>(null);
const enrichRef = useRef(0);
@@ -122,7 +128,7 @@ export const RequestConfirmationModal = ({
setIsSubmitting(true);
try {
const nextPayload = applyRequestNoteToPayload(payload, note, allowNotes);
const success = await onConfirm(nextPayload);
const success = await onConfirm(nextPayload, extraPayloads.length > 0 ? extraPayloads : undefined);
if (!success) {
setIsSubmitting(false);
}
@@ -134,25 +140,25 @@ export const RequestConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-xl rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-xl rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Request Book
{extraPayloads.length > 0 ? 'Request Book & Audiobook' : 'Request Book'}
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close request confirmation"
disabled={isSubmitting}
>
@@ -163,9 +169,9 @@ export const RequestConfirmationModal = ({
</header>
<div className="space-y-4 px-6 py-5">
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-4">
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
<div className="flex gap-4">
<div className="w-16 h-24 flex-shrink-0 rounded-lg overflow-hidden border border-[var(--border-muted)] bg-[var(--bg)]">
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border border-(--border-muted) bg-(--bg)">
{preview.preview ? (
<img
src={preview.preview}
@@ -194,8 +200,24 @@ export const RequestConfirmationModal = ({
)}
</div>
)}
{preview.releaseLine && (
<p className="text-xs opacity-60 mt-1.5">{preview.releaseLine}</p>
{/* Release lines — show all (primary + extras) with content type labels when combined */}
{(preview.releaseLine || extraPreviews.length > 0) && (
<div className="mt-1.5 space-y-0.5">
{preview.releaseLine && (
<p className="text-xs opacity-60">
{extraPreviews.length > 0 && (
<span className="font-medium opacity-80">{payload.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
)}
{preview.releaseLine}
</p>
)}
{extraPreviews.map((ep, i) => ep.releaseLine && (
<p key={i} className="text-xs opacity-60">
<span className="font-medium opacity-80">{extraPayloads[i]?.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
{ep.releaseLine}
</p>
))}
</div>
)}
</div>
</div>
@@ -212,7 +234,7 @@ export const RequestConfirmationModal = ({
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
maxLength={MAX_REQUEST_NOTE_LENGTH}
rows={4}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm resize-y min-h-[96px] focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
placeholder="Add context for admins reviewing this request..."
disabled={isSubmitting}
/>
@@ -223,12 +245,12 @@ export const RequestConfirmationModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -311,7 +311,7 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
isOpen ? 'bg-[var(--hover-action)]' : ''
isOpen ? 'bg-(--hover-action)' : ''
} animate-pop-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}
+216 -93
View File
@@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import { useSearchMode } from '../contexts/SearchModeContext';
import { Tooltip } from './shared/Tooltip';
import {
ContentType,
MetadataSearchField,
@@ -26,6 +27,7 @@ interface SearchBarProps {
onSubmit: () => void;
isLoading?: boolean;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
placeholder?: string;
inputAriaLabel?: string;
className?: string;
@@ -33,8 +35,6 @@ interface SearchBarProps {
controlsClassName?: string;
clearButtonLabel?: string;
clearButtonTitle?: string;
advancedButtonLabel?: string;
advancedButtonTitle?: string;
searchButtonLabel?: string;
searchButtonTitle?: string;
autoComplete?: string;
@@ -42,6 +42,8 @@ interface SearchBarProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
onQueryTargetChange?: (target: string) => void;
@@ -89,25 +91,39 @@ const autocompleteOptionsCache = new Map<string, DynamicFieldOption[]>();
const AUTOCOMPLETE_CACHE_MAX = 100;
const BookIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
const AudiobookIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
);
const BothIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
);
const CheckIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const getDefaultPlaceholder = (
contentType: ContentType,
activeQueryTarget: QueryTargetOption | undefined,
fallback?: string,
isCombinedMode?: boolean,
): string => {
if (fallback) return fallback;
if (!activeQueryTarget || activeQueryTarget.source === 'general') {
if (isCombinedMode) return 'Search Books & Audiobooks';
return contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks';
}
@@ -147,6 +163,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onSubmit,
isLoading = false,
onAdvancedToggle,
isAdvancedActive = false,
placeholder,
inputAriaLabel = 'Search books',
className = '',
@@ -154,8 +171,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
controlsClassName = '',
clearButtonLabel = 'Clear search input',
clearButtonTitle = 'Clear search',
advancedButtonLabel = 'Search settings',
advancedButtonTitle = 'Search settings',
searchButtonLabel = 'Search books',
searchButtonTitle = 'Search',
autoComplete = 'off',
@@ -163,6 +178,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode = false,
onCombinedModeChange,
queryTargets = [],
activeQueryTarget = 'general',
onQueryTargetChange,
@@ -197,7 +214,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
? 'pl-3 rounded-r-full'
: 'pl-4 rounded-full';
const searchInputClass = [
'w-full min-w-0 py-3 border-0 outline-none search-input bg-transparent',
'w-full min-w-0 py-3 border-0 outline-hidden search-input bg-transparent',
inputPaddingClass,
].join(' ');
@@ -218,15 +235,21 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
};
}, []);
// Auto-open select dropdown only when transitioning into a select field
const prevFieldKeyRef = useRef<string | undefined>(undefined);
// Auto-open select dropdown only when transitioning into a select field.
// Initialise the ref to the current key so that mounting with an already-
// active select field (e.g. the Header SearchBar after first search)
// doesn't count as a field change. Using `if (fieldChanged)` instead of
// always calling setIsSelectOpen avoids StrictMode's second effect
// invocation resetting the state set by the first.
const prevFieldKeyRef = useRef<string | undefined>(activeQueryField?.key);
useEffect(() => {
const fieldKey = activeQueryField?.key;
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
prevFieldKeyRef.current = fieldKey;
const fieldChanged = activeQueryField?.key !== prevFieldKeyRef.current;
prevFieldKeyRef.current = activeQueryField?.key;
setIsSelectOpen(fieldChanged && isSelect);
if (fieldChanged) {
setIsSelectOpen(isSelect);
}
setIsAutocompleteOpen(false);
setAutocompleteOptions([]);
}, [activeQueryField?.key, activeQueryField?.type]);
@@ -264,7 +287,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
fetchFieldOptions(dynamicEndpoint).then((loaded) => {
if (cancelled) return;
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label })));
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label, group: o.group })));
setIsDynamicLoading(false);
}).catch(() => {
if (cancelled) return;
@@ -386,6 +409,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const handleContentTypeSelect = (type: ContentType) => {
onContentTypeChange?.(type);
onCombinedModeChange?.(false);
setIsSelectorOpen(false);
};
const handleCombinedModeSelect = () => {
if (combinedMode) {
// Toggle off — revert to ebook-only
onCombinedModeChange?.(false);
} else {
onContentTypeChange?.('ebook');
onCombinedModeChange?.(true);
}
setIsSelectorOpen(false);
};
@@ -394,7 +429,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
setIsSelectorOpen(false);
};
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder);
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder, combinedMode);
const effectiveInputAriaLabel = activeTarget
? `${inputAriaLabel}: ${activeTarget.label}`
: inputAriaLabel;
@@ -406,7 +441,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
&& textInputValue.trim().length >= autocompleteMinQueryLength;
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
const controlsClasses = [
'flex items-center gap-1 pr-2 flex-shrink-0',
'flex items-center gap-1 pr-2 shrink-0',
controlsClassName,
]
.filter(Boolean)
@@ -533,7 +568,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<span className="opacity-50 truncate">{effectivePlaceholder}</span>
)}
<svg
className={`w-3.5 h-3.5 opacity-40 flex-shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
className={`w-3.5 h-3.5 opacity-40 shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -553,7 +588,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
className="h-4 w-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
{activeQueryField.label}
@@ -576,9 +611,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
{showQueryTargetSelector && (
<div
className="relative flex-shrink-0 flex self-stretch"
className="relative shrink-0 flex self-stretch"
ref={selectorRef}
onMouseEnter={() => {
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
if (selectorHoverTimeout.current) {
clearTimeout(selectorHoverTimeout.current);
selectorHoverTimeout.current = null;
@@ -587,7 +623,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onMouseLeave={() => {
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return;
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
@@ -599,11 +636,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); setIsAutocompleteOpen(false); }}
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
style={{ color: 'var(--text)' }}
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
aria-label={`Searching ${combinedMode ? 'books and audiobooks' : contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
aria-expanded={isSelectorOpen}
aria-haspopup="dialog"
>
{contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
{combinedMode ? <BothIcon /> : contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
{showActiveTargetLabel && (
<span className="hidden max-w-24 truncate text-sm font-medium sm:inline">
{activeTarget?.label}
@@ -638,44 +675,147 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
{showContentTypeSelector && (
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Content
<div className={`border-b ${onCombinedModeChange ? 'pb-0' : 'pb-3'}`} style={{ borderColor: 'var(--border-muted)' }}>
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Content</span>
{onAdvancedToggle && (
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`flex items-center gap-1.5 px-4 py-2.5 -mt-1.5 -mb-0.5 -mr-1 text-xs font-medium rounded-xl transition-colors ${
isAdvancedActive
? 'bg-emerald-600 text-white'
: 'hover-surface'
}`}
style={isAdvancedActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }}
>
<svg
className="w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'ebook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'ebook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
style={contentType === 'ebook' || combinedMode
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
<BookIcon />
{contentType === 'ebook' || combinedMode ? <CheckIcon /> : <BookIcon />}
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'audiobook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'audiobook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
style={contentType === 'audiobook' || combinedMode
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
<AudiobookIcon />
{contentType === 'audiobook' || combinedMode ? <CheckIcon /> : <AudiobookIcon />}
<span>Audiobooks</span>
</button>
</div>
{onCombinedModeChange && (() => {
const lineColor = combinedMode ? 'bg-emerald-500' : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
return (
<Tooltip content="Combined search" position="bottom" triggerClassName="w-full">
<button
type="button"
onClick={handleCombinedModeSelect}
className="group w-full"
aria-label="Combined search"
>
{/* Bracket connector: vertical drops + horizontal bar with icon */}
<div className="relative flex items-end h-7">
{/* Left vertical */}
<div className={`absolute left-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
{/* Right vertical */}
<div className={`absolute right-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
{/* Horizontal bar – left segment */}
<div className={`absolute left-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
{/* Horizontal bar – right segment */}
<div className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
{/* Chain icon centered at bottom */}
<div className={`mx-auto relative z-10 p-1 rounded-full transition-colors ${
combinedMode
? 'bg-emerald-600 text-white'
: 'bg-(--bg) text-zinc-400 dark:text-zinc-500 group-hover:bg-zinc-200 dark:group-hover:bg-zinc-700 group-hover:text-zinc-600 dark:group-hover:text-zinc-300'
}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
</div>
</button>
</Tooltip>
);
})()}
</div>
)}
<div className={showContentTypeSelector ? 'pt-3' : ''}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Search By
<div className={showContentTypeSelector ? 'pt-2' : ''}>
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Search By</span>
{!showContentTypeSelector && onAdvancedToggle && (
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`flex items-center gap-1.5 px-4 py-2.5 -mt-1.5 -mb-0.5 -mr-1 text-xs font-medium rounded-xl transition-colors ${
isAdvancedActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={isAdvancedActive
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }}
>
<svg
className="w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{queryTargets.map((target) => {
@@ -687,19 +827,21 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onClick={() => handleQueryTargetSelect(target.key)}
title={target.description || target.label}
aria-label={target.label}
className={`min-w-0 rounded-xl border px-3 py-2.5 text-left text-sm font-medium transition-colors ${
className={`min-w-0 rounded-xl border px-3 py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface'
}`}
style={isActive
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
{isActive && <CheckIcon />}
<span className="block truncate">{target.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
@@ -733,32 +875,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
</button>
)}
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={advancedButtonLabel}
title={advancedButtonTitle}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
)}
<button
ref={buttonRef}
type="button"
@@ -790,7 +906,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
<div className="spinner w-5 h-5 border-2 border-white border-t-transparent search-bar-spinner" />
)}
</button>
</div>
@@ -804,34 +920,41 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
aria-label={effectiveInputAriaLabel}
>
<div className="max-h-64 overflow-y-auto py-1.5">
{selectOptions.map((option) => {
{selectOptions.map((option, index) => {
const currentValue = typeof value === 'string' ? value : String(value ?? '');
const isSelected = option.value === currentValue;
const showGroupHeader = option.group != null && (index === 0 || option.group !== selectOptions[index - 1]?.group);
return (
<button
type="button"
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<div key={option.value}>
{showGroupHeader && (
<div className="px-5 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
</button>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
</div>
);
})}
</div>
@@ -30,6 +30,8 @@ interface SearchSectionProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
activeQueryField?: MetadataSearchField | null;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
@@ -59,6 +61,8 @@ export const SearchSection = ({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode,
onCombinedModeChange,
activeQueryField,
searchMode,
onSearchModeChange,
@@ -92,14 +96,22 @@ export const SearchSection = ({
onSubmit={onSearch}
isLoading={isLoading}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={showAdvanced}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={onQueryTargetChange}
activeQueryField={activeQueryField}
/>
{activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-2">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<AdvancedFilters
visible={showAdvanced}
bookLanguages={bookLanguages}
@@ -114,7 +126,9 @@ export const SearchSection = ({
activeMetadataProvider={activeMetadataProvider}
onMetadataProviderChange={onMetadataProviderChange}
contentType={contentType}
combinedMode={combinedMode}
isAdmin={isAdmin}
onClose={onAdvancedToggle}
/>
</div>
</section>
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-1100 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
@@ -43,7 +43,7 @@ interface ActivityCardProps {
}
const BookFallback = () => (
<div className="w-12 h-[4.5rem] rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
<div className="w-12 h-18 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
No Cover
</div>
);
@@ -233,7 +233,7 @@ const hasAttachedReleaseData = (record: RequestRecord): boolean => {
const DetailField = ({ label, value }: { label: string; value: string }) => (
<div className="py-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{label}</p>
<p className="text-xs font-medium break-words mt-0.5">{value}</p>
<p className="text-xs font-medium wrap-break-word mt-0.5">{value}</p>
</div>
);
@@ -507,7 +507,7 @@ export const ActivityCard = ({
const requestType = reviewRecord?.content_type === 'audiobook' ? 'Audiobook' : 'Book';
const titleAuthorLine = item.author ? `${item.title} — ${item.author}` : item.title;
const titleLineClassName = isDetailsExpanded
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
? 'text-sm leading-tight min-w-0 whitespace-normal wrap-break-word'
: 'text-sm truncate leading-tight min-w-0';
const canShowDownloadLink =
@@ -558,7 +558,7 @@ export const ActivityCard = ({
)}
<div className="flex gap-3 items-start">
{/* Artwork */}
<div className="w-12 h-[4.5rem] rounded flex-shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
<div className="w-12 h-18 rounded-sm shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
{item.preview ? (
<img
src={item.preview}
@@ -587,7 +587,7 @@ export const ActivityCard = ({
</p>
</Tooltip>
</div>
<div className="flex-shrink-0 inline-flex items-center gap-1 -my-1">
<div className="shrink-0 inline-flex items-center gap-1 -my-1">
{actions.map((action) => {
const config = actionUiConfig(action);
const icon =
@@ -746,7 +746,7 @@ export const ActivityCard = ({
type="button"
onClick={handleReviewManualApproval}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
@@ -756,7 +756,7 @@ export const ActivityCard = ({
type="button"
onClick={handleReviewBrowseAlternatives}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Browse Alternatives
</button>
@@ -776,7 +776,7 @@ export const ActivityCard = ({
rows={3}
maxLength={MAX_ADMIN_NOTE_LENGTH}
placeholder="Optional note shown to the user"
className="w-full px-2.5 py-2 rounded-md border border-[var(--border-muted)] bg-[var(--bg)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
className="w-full px-2.5 py-2 rounded-md border border-(--border-muted) bg-(--bg) text-xs resize-y min-h-[72px] focus:outline-hidden focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
disabled={isRejectSubmitting}
/>
<div className="flex items-center justify-between">
@@ -786,7 +786,7 @@ export const ActivityCard = ({
type="button"
onClick={onRequestRejectClose}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -657,7 +657,7 @@ export const ActivitySidebar = ({
<Dropdown
align="right"
widthClassName="w-auto"
panelClassName="min-w-[11rem]"
panelClassName="min-w-44"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
@@ -735,7 +735,7 @@ export const ActivitySidebar = ({
</div>
{activeTab !== 'history' && (
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
<div className="mt-2 border-b border-(--border-muted) -mx-4 px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
<div
@@ -1011,7 +1011,7 @@ export const ActivitySidebar = ({
return (
<aside
className="hidden lg:flex fixed right-0 w-96 flex-col bg-[var(--bg-soft)] z-30 rounded-2xl shadow-lg overflow-hidden"
className="hidden lg:flex fixed right-0 w-96 flex-col bg-(--bg-soft) z-30 rounded-2xl shadow-lg overflow-hidden"
style={{
top: `${pinnedTopOffset}px`,
height: `calc(100dvh - ${pinnedTopOffset}px - 0.75rem)`,
@@ -1028,7 +1028,7 @@ export const ActivitySidebar = ({
return (
<>
<div
className={`fixed inset-0 bg-black/50 z-[45] transition-opacity duration-300 ${
className={`fixed inset-0 bg-black/50 z-45 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
@@ -7,7 +7,7 @@ import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CardViewProps {
@@ -62,7 +62,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: book.cover_aspect === 'square' ? '1/1' : '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
@@ -122,12 +122,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -156,7 +156,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span>•</span>
<DisplayFieldBadges fields={book.display_fields} />
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions')} />
</div>
) : (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
@@ -177,7 +177,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
@@ -4,10 +4,10 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CompactViewProps {
@@ -51,7 +51,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
className="book-card flex! flex-row! w-full h-[180px]! transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -63,7 +63,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
<div className="relative w-[120px] h-full shrink-0">
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
@@ -121,12 +121,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -159,7 +159,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
<div className="mt-auto flex flex-col gap-2">
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<DisplayFieldBadges fields={book.display_fields} className="text-xs opacity-70" />
<>
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions' && f.icon !== 'microphone')} className="text-xs opacity-70" />
{book.display_fields.find(f => f.icon === 'microphone') && (
<div className="flex items-center gap-0.5 text-xs opacity-70">
<DisplayFieldIcon icon="microphone" />
<span>{book.display_fields.find(f => f.icon === 'microphone')!.value}</span>
</div>
)}
</>
) : (
<div className="text-xs opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>
@@ -177,7 +185,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded border text-xs flex-shrink-0 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border text-xs shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
@@ -18,14 +18,16 @@ interface ListViewProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string; title?: string; coverAspect?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const isSquare = coverAspect === 'square';
const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14';
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
className={`${sizeClass} rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300`}
aria-label="No cover available"
>
No Cover
@@ -34,14 +36,14 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
<div className={`relative ${sizeClass} rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70`}>
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
alt={title || 'Book cover'}
className="w-full h-full object-cover object-top"
className={`w-full h-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
@@ -116,12 +118,12 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Universal mode uses separate columns for each display field, direct mode uses language/format/size */}
<div className={`grid items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full ${
searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(80px,0.4fr)_minmax(80px,0.4fr)_auto]'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]'
}`}>
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} />
<ListViewThumbnail preview={book.preview} title={book.title} coverAspect={book.cover_aspect} />
</div>
{/* Title and Author */}
@@ -129,7 +131,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2 flex items-center gap-2" title={book.title || 'Untitled'}>
{showSeriesPosition && book.series_position != null && (
<span
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded border border-emerald-700 flex-shrink-0"
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded-sm border border-emerald-700 shrink-0"
style={{
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -149,7 +151,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Mobile universal mode info */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
book.display_fields.slice(0, 2).map((field, idx) => (
book.display_fields.filter(f => f.icon !== 'editions').slice(0, 2).map((field, idx) => (
<span key={idx} className="flex items-center gap-0.5" title={field.label}>
<DisplayFieldIcon icon={field.icon} />
<span>{field.value}</span>
@@ -171,18 +173,26 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Universal mode: Display fields as separate columns - Desktop only */}
{searchMode === 'universal' && (
<>
{/* First display field column */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[0] ? (
<DisplayFieldBadge field={book.display_fields[0]} />
{/* Rating column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'star') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'star')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Second display field column */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[1] ? (
<DisplayFieldBadge field={book.display_fields[1]} />
{/* Length column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'clock' || f.icon === 'book') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'clock' || f.icon === 'book')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Narrator column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'microphone') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'microphone')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
@@ -247,18 +247,18 @@ export const SelfSettingsModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-[var(--border-muted)] shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-(--border-muted) shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="sr-only">My Account</h3>
{editingUser ? (
<UserIdentityHeader
@@ -295,7 +295,7 @@ export const SelfSettingsModal = ({
<button
type="button"
onClick={() => { void loadEditContext(); }}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)] bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
@@ -350,7 +350,7 @@ export const SelfSettingsModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
<UserEditActions
variant="modalFooter"
onSave={() => {
@@ -12,7 +12,7 @@ export const SettingsHeader = ({
onClose,
}: SettingsHeaderProps) => (
<header
className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border-muted)] flex-shrink-0"
className="flex items-center gap-3 px-5 py-4 border-b border-(--border-muted) shrink-0"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
{showBack && (
@@ -319,7 +319,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center gap-3">
@@ -356,7 +356,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl max-w-md"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
@@ -380,8 +380,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -441,7 +440,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
{/* Modal */}
<div
className={`relative w-full max-w-4xl h-[85vh] max-h-[750px] rounded-xl
border border-[var(--border-muted)] shadow-2xl
border border-(--border-muted) shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -213,13 +213,13 @@ export const SettingsSidebar = ({
<button
onClick={() => onSelectTab(item.tab.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.tab.icon)}</span>
<span className="flex-1">{item.tab.displayName}</span>
</button>
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
)}
</div>
);
@@ -232,7 +232,7 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -240,18 +240,18 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="bg-[var(--bg-soft)]/50">
<div className="bg-(--bg-soft)/50">
{item.tabs.map((tab, index) => (
<div key={tab.name}>
<button
onClick={() => onSelectTab(tab.name)}
className="w-full flex items-center gap-4 pl-14 pr-5 py-3.5
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
)}
</div>
))}
@@ -259,7 +259,7 @@ export const SettingsSidebar = ({
)}
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
)}
</div>
);
@@ -270,7 +270,7 @@ export const SettingsSidebar = ({
// Desktop: Sidebar navigation
return (
<nav className="w-60 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
<nav className="w-60 border-r border-(--border-muted) py-2 shrink-0 overflow-y-auto">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
@@ -290,8 +290,8 @@ export const SettingsSidebar = ({
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors ${
selectedTab === item.tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span className="opacity-60">{getIcon(item.tab.icon)}</span>
@@ -309,8 +309,8 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors hover:bg-[var(--hover-surface)]
${hasSelectedTab && !isExpanded ? 'bg-[var(--hover-action)]/50' : ''}`}
transition-colors hover:bg-(--hover-surface)
${hasSelectedTab && !isExpanded ? 'bg-(--hover-action)/50' : ''}`}
>
<span className="opacity-60">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -318,16 +318,16 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="ml-8 border-l border-[var(--border-muted)]">
<div className="ml-[22px] border-l border-(--border-muted) flex flex-col gap-0.5 pl-3">
{item.tabs.map((tab) => (
<button
key={tab.name}
onClick={() => onSelectTab(tab.name)}
className={`w-full flex items-center pl-4 pr-4 py-2 text-sm text-left
className={`w-full flex items-center pl-3 pr-4 py-2 text-sm text-left
transition-colors ${
selectedTab === tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span>{tab.displayName}</span>
@@ -2,9 +2,9 @@ import { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
<div
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml
@@ -31,7 +31,7 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
const styleClasses = {
default:
'bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)]',
'bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface)',
primary: 'bg-sky-600 text-white hover:bg-sky-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t not-first:border-(--border-muted)">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">
@@ -163,7 +163,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
if (isDisabled) {
return (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{summaryFormatter()}
</div>
);
@@ -282,7 +282,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
${
isSelected
? 'bg-sky-600 text-white border-sky-600'
: 'bg-transparent border-[var(--border-muted)] hover:bg-[var(--hover-surface)]'
: 'bg-transparent border-(--border-muted) hover:bg-(--hover-surface)'
}`}
>
{opt.label}
@@ -20,9 +20,8 @@ export const NumberField = ({ field, value, onChange, disabled }: NumberFieldPro
max={field.max}
step={field.step ?? 1}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -233,13 +233,12 @@ export const OrderableListField = ({
flex items-center gap-3 p-3 rounded-lg
transition-all duration-150
${isDragging ? 'opacity-50 cursor-grabbing' : isPinned ? 'cursor-default' : 'cursor-grab'}
border border-[var(--border-muted)]
${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-[var(--hover-surface)]' : ''}
border border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
`}
>
{/* Reorder Controls - hidden for pinned items */}
{!isPinned ? (
<div className="flex flex-col flex-shrink-0 -my-1">
<div className="flex flex-col shrink-0 -my-1">
<button
type="button"
onClick={(e) => {
@@ -282,14 +281,14 @@ export const OrderableListField = ({
</button>
</div>
) : (
<div className="w-5 sm:w-4 flex-shrink-0" />
<div className="w-5 sm:w-4 shrink-0" />
)}
{/* Label and Description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)] mt-0.5">
<div className="text-xs text-(--text-muted) mt-0.5">
{item.description}
</div>
)}
@@ -308,7 +307,7 @@ export const OrderableListField = ({
</div>
{/* Toggle Switch */}
<div onClick={(e) => e.stopPropagation()} className="flex-shrink-0">
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<ToggleSwitch
checked={item.enabled && !item.isLocked}
onChange={() => toggleItem(index)}
@@ -21,9 +21,8 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className="w-full px-3 py-2 pr-10 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 pr-10 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -32,7 +31,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
onClick={() => setShowPassword(!showPassword)}
disabled={isDisabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded
hover:bg-[var(--hover-action)] transition-colors
hover:bg-(--hover-action) transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
@@ -69,7 +69,7 @@ export const SelectField = ({ field, value, onChange, disabled, filterValue }: S
// When disabled, show a static display instead of the dropdown
const selectedOption = filteredOptions.find((opt) => opt.value === effectiveValue);
return (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{selectedOption?.label || 'Select...'}
</div>
);
@@ -88,7 +88,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:[grid-template-columns:var(--table-cols)]';
const gridTemplate = 'sm:grid-cols-(--table-cols)';
const tableCols = useMemo(() => {
if (columns.length === 0) {
@@ -175,8 +175,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -237,7 +236,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) shadow-sm bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
</div>
) : (
@@ -301,9 +300,8 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
placeholder={col.placeholder}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -333,7 +331,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
</button>
</div>
<div className="col-span-full border-t border-[var(--border-muted)] opacity-60" />
<div className="col-span-full border-t border-(--border-muted) opacity-60" />
</div>
))}
</div>
@@ -343,8 +341,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -82,9 +82,8 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
className={`w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
focus-within:outline-hidden focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
transition-colors
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
onClick={() => {
@@ -92,16 +91,16 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1 items-center min-h-[1.25rem]">
<div className="flex flex-wrap gap-1 items-center min-h-5">
{tags.map((tag, idx) => (
<span
key={`${tag}-${idx}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
border border-[var(--border-muted)] bg-[var(--bg)]
border border-(--border-muted) bg-(--bg)
max-w-full"
title={tag}
>
<span className="truncate max-w-[22rem]">{tag}</span>
<span className="truncate max-w-88">{tag}</span>
{!isDisabled && !isRequired(tag) && (
<button
type="button"
@@ -109,7 +108,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
e.stopPropagation();
removeAt(idx);
}}
className="p-0.5 rounded hover:bg-[var(--hover-surface)]"
className="p-0.5 rounded-sm hover:bg-(--hover-surface)"
aria-label={`Remove ${tag}`}
>
<svg
@@ -146,7 +145,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
}}
onBlur={() => commitDraft()}
placeholder={tags.length === 0 ? field.placeholder : ''}
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0"
className="flex-1 min-w-16 bg-transparent outline-hidden px-1 py-0"
/>
)}
@@ -19,9 +19,8 @@ export const TextField = ({ field, value, onChange, disabled }: TextFieldProps)
placeholder={field.placeholder}
maxLength={field.maxLength}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
@@ -5,7 +5,7 @@ interface SettingsSaveBarProps {
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
className="shrink-0 px-6 py-4 border-t border-(--border-muted) bg-(--bg) animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
@@ -142,23 +142,23 @@ export const RequestPolicyGrid = ({
type="button"
onClick={onClearOverrides}
disabled={clearOverridesDisabled}
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-[var(--border-muted)] bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear all overrides
</button>
</div>
)}
<div className="rounded-lg border border-[var(--border-muted)]">
<div className="rounded-lg border border-(--border-muted)">
{/* Header */}
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-[var(--bg-soft)] text-xs font-medium opacity-60 border-b border-[var(--border-muted)] rounded-t-lg">
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-(--bg-soft) text-xs font-medium opacity-60 border-b border-(--border-muted) rounded-t-lg">
<span>Source</span>
<span>Ebook</span>
<span>Audiobook</span>
</div>
{/* Default row */}
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center bg-[var(--bg-soft)] border-b-2 border-[var(--border-muted)]">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center bg-(--bg-soft) border-b-2 border-(--border-muted)">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">Default</p>
</div>
@@ -178,7 +178,7 @@ export const RequestPolicyGrid = ({
<div key={contentType} className="flex items-center gap-1.5">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg) text-sm opacity-60 cursor-not-allowed">
{REQUEST_POLICY_MODE_LABELS[mode]}
</div>
) : (
@@ -221,7 +221,7 @@ export const RequestPolicyGrid = ({
<div
key={sourceRow.source}
className={`grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center ${
index > 0 ? 'border-t border-[var(--border-muted)]' : ''
index > 0 ? 'border-t border-(--border-muted)' : ''
}`}
>
<div className="min-w-0">
@@ -295,7 +295,7 @@ export const RequestPolicyGrid = ({
}`}
>
{rulesDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</div>
) : (
@@ -9,7 +9,7 @@ import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="space-y-5 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)]">
<div className="space-y-5 p-4 rounded-lg border border-(--border-muted) bg-(--bg)">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
@@ -147,10 +147,10 @@ export const UserRoleControl = ({
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
buttonClassName={`py-1! px-2.5! text-xs! font-medium! ${
user.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
? 'bg-sky-500/15! text-sky-600! dark:text-sky-400! border-sky-500/30!'
: 'bg-zinc-500/10! opacity-70!'
}`}
/>
);
@@ -251,7 +251,7 @@ export const UserEditActions = ({
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) shadow-sm hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -278,7 +278,7 @@ export const UserEditActions = ({
}
return (
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-col gap-2 pt-3 border-t border-(--border-muted) sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onSave}
@@ -290,8 +290,7 @@ export const UserEditActions = ({
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -310,8 +309,8 @@ export const UserEditActions = ({
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted)
bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
@@ -401,8 +400,7 @@ export const UserCreateCard = ({
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
>
Cancel
</button>
@@ -574,7 +572,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t border-(--border-muted)" />
</>
)}
@@ -598,7 +596,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t border-(--border-muted)" />
{preferencesContent}
</>
)}
@@ -96,8 +96,7 @@ export const UserListView = ({
<p className="text-sm opacity-60">{loadError}</p>
<button
onClick={onRetryLoadUsers}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
>
Retry
</button>
@@ -118,7 +117,7 @@ export const UserListView = ({
return (
<div
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
className={`rounded-lg border border-(--border-muted) bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
>
<div
role="button"
@@ -144,7 +143,7 @@ export const UserListView = ({
}
}
}}
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b border-[var(--border-muted)]' : 'rounded-b-lg'}`}
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b border-(--border-muted)' : 'rounded-b-lg'}`}
aria-expanded={isEditingRow}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
>
@@ -185,7 +184,7 @@ export const UserListView = ({
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
<div className="p-4 space-y-5 bg-(--bg) rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserAccountCardContent
user={editingUser}

Some files were not shown because too many files have changed in this diff Show More