mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019d36b27e | ||
|
|
698eb07e71 | ||
|
|
8f949a73d5 | ||
|
|
2c6f46fc88 | ||
|
|
3f90c3805f | ||
|
|
cb093f61c6 | ||
|
|
b464d62672 | ||
|
|
f3f26488b1 | ||
|
|
fec9d31c8a | ||
|
|
3295be82a7 | ||
|
|
fff0fd07a1 | ||
|
|
3f1a14843b | ||
|
|
21a11b06b9 | ||
|
|
0d856a3ef5 | ||
|
|
ebf4312174 | ||
|
|
685c35d552 | ||
|
|
3d72f9e258 | ||
|
|
7f79da11e6 | ||
|
|
c59ea46540 |
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.local
|
||||
.mypy_cache
|
||||
README_images
|
||||
.gitignore
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase==4.45.10
|
||||
seleniumbase==4.47.3
|
||||
python-xlib
|
||||
|
||||
Executable
+246
@@ -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 "$@"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+694
-1005
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -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,6 +1,3 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}'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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user