Compare commits

...
Author SHA1 Message Date
Alex 4881adc19f Fix tor error retry (#941) 2026-05-01 19:30:32 +01:00
Alex ee54033d23 Fix entrypoint handling on Unraid (#939) 2026-05-01 18:41:43 +01:00
Alex 3554d01c81 Change path default for audiobooks + description fixes (#933) 2026-04-30 18:20:05 +01:00
Alex 9dd445f2af Revert image size streaming (#932) 2026-04-30 17:07:43 +01:00
Alex 4e41b1a8ec fix: hardcover author search (#929)
- Re-adds author search suggestions for Hardcover
- Correctly routes author queries to correct ID or best match
author-filtered book query.
2026-04-29 19:10:37 +01:00
28 changed files with 515 additions and 565 deletions
+75 -13
View File
@@ -14,6 +14,7 @@ This document lists all configuration options that can be set via environment va
- [Network](#network) - [Network](#network)
- [Advanced](#advanced) - [Advanced](#advanced)
- [Prowlarr](#prowlarr) - [Prowlarr](#prowlarr)
- [Newznab](#newznab)
- [AudiobookBay](#audiobookbay) - [AudiobookBay](#audiobookbay)
- [IRC](#irc) - [IRC](#irc)
- [Download Clients](#download-clients) - [Download Clients](#download-clients)
@@ -124,6 +125,7 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
| Variable | Description | Type | Default | | Variable | Description | Type | Default |
|----------|-------------|------|---------| |----------|-------------|------|---------|
| `SEARCH_PAGE_TITLE` | Title shown above the main search box on the homepage. | string | `Shelfmark` |
| `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc). | string | _none_ | | `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc). | string | _none_ |
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ | | `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` | | `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
@@ -133,6 +135,15 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
<details> <details>
<summary>Detailed descriptions</summary> <summary>Detailed descriptions</summary>
#### `SEARCH_PAGE_TITLE`
**Search Page Title**
Title shown above the main search box on the homepage.
- **Type:** string
- **Default:** `Shelfmark`
#### `CALIBRE_WEB_URL` #### `CALIBRE_WEB_URL`
**Library URL** **Library URL**
@@ -294,8 +305,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` | | `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` | | `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` | | `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` | | `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` | | `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` | | `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ | | `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ | | `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
@@ -311,13 +322,13 @@ The release source tab to open by default in the release modal for audiobooks. U
| `EMAIL_SMTP_USERNAME` | SMTP username (leave empty for no authentication). | string | _none_ | | `EMAIL_SMTP_USERNAME` | SMTP username (leave empty for no authentication). | string | _none_ |
| `EMAIL_SMTP_PASSWORD` | SMTP password (required if Username is set). | string (secret) | _none_ | | `EMAIL_SMTP_PASSWORD` | SMTP password (required if Username is set). | string (secret) | _none_ |
| `EMAIL_FROM` | From address used for the email. You can include a display name (e.g., Shelfmark <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address). | string | _none_ | | `EMAIL_FROM` | From address used for the email. You can include a display name (e.g., Shelfmark <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address). | string | _none_ |
| `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` | | `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` |
| `EMAIL_SMTP_TIMEOUT_SECONDS` | How long to wait for SMTP operations before failing. | number | `60` | | `EMAIL_SMTP_TIMEOUT_SECONDS` | How long to wait for SMTP operations before failing. | number | `60` |
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` | | `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ | | `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` | | `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_AUDIOBOOK_RENAME` | 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. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` | | `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `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}` | | `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{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` | | `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` | | `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ | | `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
@@ -361,7 +372,7 @@ Choose how downloaded book files are named and organized.
**Naming Template** **Naming Template**
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string - **Type:** string
- **Default:** `{Author} - {Title} ({Year})` - **Default:** `{Author} - {Title} ({Year})`
@@ -370,7 +381,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
**Path Template** **Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string - **Type:** string
- **Default:** `{Author}/{Title} ({Year})` - **Default:** `{Author}/{Title} ({Year})`
@@ -524,7 +535,7 @@ From address used for the email. You can include a display name (e.g., Shelfmark
**Subject Template** **Subject Template**
Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.
- **Type:** string - **Type:** string
- **Default:** `{Title}` - **Default:** `{Title}`
@@ -571,7 +582,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template** **Naming Template**
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. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string - **Type:** string
- **Default:** `{Author} - {Title}` - **Default:** `{Author} - {Title}`
@@ -580,10 +591,10 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
**Path Template** **Path Template**
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. Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string - **Type:** string
- **Default:** `{Author}/{Title}` - **Default:** `{Author}/{Title}/{Title}`
#### `HARDLINK_TORRENTS_AUDIOBOOK` #### `HARDLINK_TORRENTS_AUDIOBOOK`
@@ -639,7 +650,7 @@ How long to keep completed/failed downloads in the queue display.
| Variable | Description | Type | Default | | Variable | Description | Type | Default |
|----------|-------------|------|---------| |----------|-------------|------|---------|
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` | | `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords. | string (choice) | `none` |
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` | | `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ | | `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` | | `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
@@ -661,7 +672,7 @@ How long to keep completed/failed downloads in the queue display.
**Authentication Method** **Authentication Method**
Select the authentication method for accessing Shelfmark. Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords.
- **Type:** string (choice) - **Type:** string (choice)
- **Default:** `none` - **Default:** `none`
@@ -1115,6 +1126,57 @@ Automatically retry search without category filtering if no results are found
</details> </details>
## Newznab
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` |
| `NEWZNAB_URL` | Base URL of your Newznab indexer or aggregator | string | _none_ |
| `NEWZNAB_API_KEY` | Your Newznab API key (leave blank if not required) | string (secret) | _none_ |
| `NEWZNAB_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
<details>
<summary>Detailed descriptions</summary>
#### `NEWZNAB_ENABLED`
**Enable Newznab source**
Enable searching for books via a Newznab-compatible indexer
- **Type:** boolean
- **Default:** `false`
#### `NEWZNAB_URL`
**Newznab URL**
Base URL of your Newznab indexer or aggregator
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `NEWZNAB_API_KEY`
**API Key**
Your Newznab API key (leave blank if not required)
- **Type:** string (secret)
- **Default:** _none_
#### `NEWZNAB_AUTO_EXPAND`
**Auto-expand search on no results**
Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
</details>
## AudiobookBay ## AudiobookBay
| Variable | Description | Type | Default | | Variable | Description | Type | Default |
+19 -2
View File
@@ -106,6 +106,18 @@ if [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="python3" PYTHON_BIN="python3"
fi fi
# Defensive: some orchestrators (e.g. Unraid Dockhand templates) inject a default
# PATH that drops the venv bin directory baked in by the Dockerfile. Prepend it
# so subprocesses launched without an absolute path still resolve correctly.
case ":${PATH}:" in
*":/app/.venv/bin:"*) ;;
*) export PATH="/app/.venv/bin:${PATH}" ;;
esac
GUNICORN_BIN="/app/.venv/bin/gunicorn"
if [ ! -x "$GUNICORN_BIN" ]; then
GUNICORN_BIN="gunicorn"
fi
# Print build version # Print build version
echo "Build version: $BUILD_VERSION" echo "Build version: $BUILD_VERSION"
echo "Release version: $RELEASE_VERSION" echo "Release version: $RELEASE_VERSION"
@@ -443,7 +455,7 @@ fi
# upgrades work reliably on customer machines. # upgrades work reliably on customer machines.
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase). # Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]') gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app" command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# If DEBUG and not using an external bypass # If DEBUG and not using an external bypass
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
@@ -512,7 +524,12 @@ else
fi fi
RUNTIME_HOME=$(resolve_runtime_home) RUNTIME_HOME=$(resolve_runtime_home)
require_writable_dir "$RUNTIME_HOME" "Home" if [ "$RUN_AS_NON_ROOT" = "true" ]; then
require_writable_dir "$RUNTIME_HOME" "Home"
else
mkdir -p "$RUNTIME_HOME"
make_writable "$RUNTIME_HOME" tree
fi
if [ "$RUN_AS_NON_ROOT" = "true" ]; then if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "Startup mode: non-root" echo "Startup mode: non-root"
-1
View File
@@ -23,7 +23,6 @@ dependencies = [
"transmission-rpc", "transmission-rpc",
"authlib>=1.7.0,<1.8", "authlib>=1.7.0,<1.8",
"apprise>=1.9.0", "apprise>=1.9.0",
"Pillow>=11.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+4 -12
View File
@@ -310,7 +310,7 @@ def generate_env_docs() -> str:
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]: def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
"""Generate documentation for a single settings tab.""" """Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField from shelfmark.core.settings_registry import iter_value_fields
lines = [] lines = []
@@ -323,17 +323,9 @@ def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
lines.append("") lines.append("")
# Collect env-supported fields # Collect env-supported fields
env_fields = [] env_fields = [
for field in tab.fields: field for field in iter_value_fields(tab) if getattr(field, "env_supported", True)
# Skip non-value fields ]
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
continue
# Skip fields that don't support ENV vars
if not getattr(field, "env_supported", True):
continue
env_fields.append(field)
if not env_fields: if not env_fields:
lines.append("_No environment variables for this section._") lines.append("_No environment variables for this section._")
+30 -6
View File
@@ -938,7 +938,7 @@ def download_settings() -> list[SettingsField]:
SelectField( SelectField(
key="FILE_ORGANIZATION", key="FILE_ORGANIZATION",
label="File Organization", label="File Organization",
description="Choose how downloaded book files are named and organized. ", description="Choose how downloaded book files are named and organized.",
options=[ options=[
{ {
"value": "none", "value": "none",
@@ -966,7 +966,14 @@ def download_settings() -> list[SettingsField]:
_naming_template_field( _naming_template_field(
key="TEMPLATE_RENAME", key="TEMPLATE_RENAME",
label="Naming Template", label="Naming Template",
description="Filename template for single-file book downloads.", description=(
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
"(source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
"Applies to single-file downloads."
),
default="{Author} - {Title} ({Year})", default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})", placeholder="{Author} - {Title} ({Year})",
show_when=[ show_when=[
@@ -978,7 +985,12 @@ def download_settings() -> list[SettingsField]:
_naming_template_field( _naming_template_field(
key="TEMPLATE_ORGANIZE", key="TEMPLATE_ORGANIZE",
label="Path Template", label="Path Template",
description="Folder and filename template for book downloads.", description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
),
default="{Author}/{Title} ({Year})", default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})", placeholder="{Author}/{Series/}{Title} ({Year})",
show_when=[ show_when=[
@@ -1236,7 +1248,14 @@ def download_settings() -> list[SettingsField]:
_naming_template_field( _naming_template_field(
key="TEMPLATE_AUDIOBOOK_RENAME", key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template", label="Naming Template",
description="Filename template for single-file audiobook downloads.", description=(
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
"Applies to single-file downloads."
),
default="{Author} - {Title}", default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}", placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"}, show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
@@ -1246,8 +1265,13 @@ def download_settings() -> list[SettingsField]:
_naming_template_field( _naming_template_field(
key="TEMPLATE_AUDIOBOOK_ORGANIZE", key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template", label="Path Template",
description="Folder and filename template for audiobook downloads.", description=(
default="{Author}/{Title}", "Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
),
default="{Author}/{Title}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}", placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"}, show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
universal_only=True, universal_only=True,
-161
View File
@@ -49,9 +49,6 @@ TRANSIENT_CACHE_TTL = 60
_MIN_WEBP_HEADER_LENGTH = 12 _MIN_WEBP_HEADER_LENGTH = 12
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
MAX_VARIANT_DIMENSION = 1024
WEBP_DEFAULT_QUALITY = 80
JPEG_DEFAULT_QUALITY = 85
def _detect_image_type(data: bytes) -> tuple[str, str] | None: def _detect_image_type(data: bytes) -> tuple[str, str] | None:
@@ -75,164 +72,6 @@ def _detect_image_type(data: bytes) -> tuple[str, str] | None:
return None return None
def normalize_variant_dimension(value: object) -> int | None:
"""Normalize a requested variant dimension, clamping to a safe upper bound."""
dimension = coerce_int(value, 0)
if dimension <= 0:
return None
return min(dimension, MAX_VARIANT_DIMENSION)
def normalize_variant_format(value: object) -> str | None:
"""Normalize a requested output image format."""
if not isinstance(value, str):
return None
normalized = value.strip().lower()
if normalized in {"jpg", "jpeg"}:
return "jpeg"
if normalized in {"png", "webp"}:
return normalized
return None
def build_variant_cache_id(
cache_id: str,
*,
width: int | None,
height: int | None,
image_format: str | None,
) -> str:
"""Build a cache key for a derived cover variant."""
width_token = str(width) if width is not None else "auto"
height_token = str(height) if height is not None else "auto"
format_token = image_format or "original"
return f"{cache_id}__w{width_token}_h{height_token}_f{format_token}"
def _calculate_variant_size(
*,
source_width: int,
source_height: int,
width: int | None,
height: int | None,
) -> tuple[int, int]:
"""Calculate the output size while preserving aspect ratio and avoiding upscaling."""
if width is None and height is None:
return source_width, source_height
width_ratio = (width / source_width) if width is not None else None
height_ratio = (height / source_height) if height is not None else None
if width_ratio is not None and height_ratio is not None:
scale = min(width_ratio, height_ratio, 1.0)
elif width_ratio is not None:
scale = min(width_ratio, 1.0)
elif height_ratio is not None:
scale = min(height_ratio, 1.0)
else:
scale = 1.0
return (
max(1, round(source_width * scale)),
max(1, round(source_height * scale)),
)
def _normalize_source_format(image_data: bytes) -> str | None:
"""Return the normalized detected source image format."""
detected = _detect_image_type(image_data)
if not detected:
return None
content_type, _ext = detected
if content_type == "image/jpeg":
return "jpeg"
if content_type == "image/png":
return "png"
if content_type == "image/webp":
return "webp"
return None
def create_image_variant(
image_data: bytes,
*,
width: int | None = None,
height: int | None = None,
image_format: str | None = None,
) -> tuple[bytes, str] | None:
"""Create a resized and/or transcoded image variant.
Returns None when no variant is needed or the image cannot be safely transformed.
"""
requested_format = normalize_variant_format(image_format)
if width is None and height is None and requested_format is None:
return None
source_format = _normalize_source_format(image_data)
try:
from PIL import Image, ImageOps, UnidentifiedImageError
except ImportError:
logger.warning("Pillow is not installed; serving original cover image")
return None
try:
with Image.open(BytesIO(image_data)) as source_image:
if getattr(source_image, "is_animated", False):
return None
image = ImageOps.exif_transpose(source_image)
source_width, source_height = image.size
output_width, output_height = _calculate_variant_size(
source_width=source_width,
source_height=source_height,
width=width,
height=height,
)
needs_resize = (output_width, output_height) != (source_width, source_height)
output_format = requested_format or source_format
if not needs_resize and output_format == source_format:
return None
if needs_resize:
image = image.resize((output_width, output_height), Image.Resampling.LANCZOS)
if output_format == "jpeg":
if image.mode not in {"RGB", "L"}:
image = image.convert("RGB")
content_type = "image/jpeg"
save_kwargs: dict[str, Any] = {
"format": "JPEG",
"quality": JPEG_DEFAULT_QUALITY,
"optimize": True,
}
elif output_format == "png":
if image.mode not in {"1", "L", "LA", "P", "PA", "RGB", "RGBA"}:
image = image.convert("RGBA")
content_type = "image/png"
save_kwargs = {"format": "PNG", "optimize": True}
else:
if image.mode not in {"RGB", "RGBA"}:
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
content_type = "image/webp"
save_kwargs = {
"format": "WEBP",
"quality": WEBP_DEFAULT_QUALITY,
"method": 6,
}
output = BytesIO()
image.save(output, **save_kwargs)
return output.getvalue(), content_type
except (OSError, UnidentifiedImageError, ValueError) as exc:
logger.warning("Failed to derive image variant: %s", exc)
return None
class ImageCacheService: class ImageCacheService:
"""Persistent image cache with LRU eviction and TTL support.""" """Persistent image cache with LRU eviction and TTL support."""
+4 -4
View File
@@ -333,7 +333,7 @@ def get_all_settings_tabs() -> list[SettingsTab]:
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name)) return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
def _iter_value_fields(tab: SettingsTab) -> Iterator[FieldBase]: def iter_value_fields(tab: SettingsTab) -> Iterator[FieldBase]:
"""Yield value-bearing fields for a tab.""" """Yield value-bearing fields for a tab."""
for settings_field in tab.fields: for settings_field in tab.fields:
if isinstance(settings_field, CustomComponentField): if isinstance(settings_field, CustomComponentField):
@@ -360,7 +360,7 @@ def get_settings_field_map(
field_map: dict[str, tuple[FieldBase, str]] = {} field_map: dict[str, tuple[FieldBase, str]] = {}
for tab in tabs: for tab in tabs:
for settings_field in _iter_value_fields(tab): for settings_field in iter_value_fields(tab):
field_map[settings_field.key] = (settings_field, tab.name) field_map[settings_field.key] = (settings_field, tab.name)
return field_map return field_map
@@ -494,7 +494,7 @@ def initialize_default_configs() -> bool:
# Collect default values for all fields # Collect default values for all fields
defaults = {} defaults = {}
for field in _iter_value_fields(tab): for field in iter_value_fields(tab):
# Only include fields that have a non-None default # Only include fields that have a non-None default
if field.default is not None: if field.default is not None:
defaults[field.key] = field.default defaults[field.key] = field.default
@@ -536,7 +536,7 @@ def sync_env_to_config() -> None:
for tab in get_all_settings_tabs(): for tab in get_all_settings_tabs():
values_to_sync = {} values_to_sync = {}
for settings_field in _iter_value_fields(tab): for settings_field in iter_value_fields(tab):
# Skip fields that don't support ENV vars # Skip fields that don't support ENV vars
if not getattr(settings_field, "env_supported", True): if not getattr(settings_field, "env_supported", True):
continue continue
+21 -65
View File
@@ -1597,9 +1597,6 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
Query Parameters: Query Parameters:
url (str): Base64-encoded original image URL (required on first request) url (str): Base64-encoded original image URL (required on first request)
w (int): Optional max width for a derived image variant
h (int): Optional max height for a derived image variant
format (str): Optional output format for a derived image variant (webp/png/jpeg)
Returns: Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404. flask.Response: Binary image data with appropriate Content-Type, or 404.
@@ -1609,84 +1606,43 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
import base64 import base64
from shelfmark.config.env import is_covers_cache_enabled from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import ( from shelfmark.core.image_cache import get_image_cache
build_variant_cache_id,
create_image_variant,
get_image_cache,
normalize_variant_dimension,
normalize_variant_format,
)
# Check if caching is enabled # Check if caching is enabled
if not is_covers_cache_enabled(): if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404 return jsonify({"error": "Cover caching is disabled"}), 404
cache = get_image_cache() cache = get_image_cache()
width = normalize_variant_dimension(request.args.get("w"))
height = normalize_variant_dimension(request.args.get("h"))
image_format = normalize_variant_format(request.args.get("format"))
variant_cache_id = (
build_variant_cache_id(
cover_id,
width=width,
height=height,
image_format=image_format,
)
if width is not None or height is not None or image_format is not None
else None
)
def make_cover_response(
image_data: bytes,
content_type: str,
*,
cache_status: str,
) -> Response:
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = cache_status
return response
# Try to get from cache first # Try to get from cache first
cache_lookup_id = variant_cache_id or cover_id cached = cache.get(cover_id)
cached = cache.get(cache_lookup_id)
if cached: if cached:
image_data, content_type = cached image_data, content_type = cached
return make_cover_response(image_data, content_type, cache_status="HIT") response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
return response
# Cache miss - get URL from query parameter # Cache miss - get URL from query parameter
encoded_url = request.args.get("url") encoded_url = request.args.get("url")
original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
if original is None: try:
if not encoded_url: original_url = base64.urlsafe_b64decode(encoded_url).decode()
return jsonify({"error": "Cover URL not provided"}), 404 except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
try: # Fetch and cache the image
original_url = base64.urlsafe_b64decode(encoded_url).decode() result = cache.fetch_and_cache(cover_id, original_url)
except (binascii.Error, UnicodeDecodeError) as e: if not result:
logger.warning("Failed to decode cover URL: %s", e) return jsonify({"error": "Failed to fetch cover image"}), 404
return jsonify({"error": "Invalid cover URL encoding"}), 400
# Fetch and cache the original image image_data, content_type = result
original = cache.fetch_and_cache(cover_id, original_url) response = app.response_class(response=image_data, status=200, mimetype=content_type)
if not original: response.headers["Cache-Control"] = "public, max-age=86400"
return jsonify({"error": "Failed to fetch cover image"}), 404 response.headers["X-Cache"] = "MISS"
image_data, content_type = original
if variant_cache_id:
variant = create_image_variant(
image_data,
width=width,
height=height,
image_format=image_format,
)
if variant:
image_data, content_type = variant
cache.put(variant_cache_id, image_data, content_type)
response = make_cover_response(image_data, content_type, cache_status="MISS")
except _IMPORT_OPERATIONAL_ERRORS as e: except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}") logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
+164 -3
View File
@@ -395,6 +395,76 @@ query GetSeriesBooks($seriesId: Int!) {
} }
""" """
AUTHOR_BOOKS_BY_ID_QUERY = """
query GetAuthorBooks($authorId: Int!, $limit: Int!, $offset: Int!) {
authors(where: {id: {_eq: $authorId}}, limit: 1) {
name
contributions(
where: {
contributable_type: {_eq: "Book"},
book: {
canonical_id: {_is_null: true},
state: {_in: ["normalized", "normalizing"]}
}
},
order_by: [
{book: {users_count: desc_nulls_last}},
{book: {ratings_count: desc_nulls_last}},
{book: {release_date: asc_nulls_last}},
{book: {id: asc}}
],
limit: $limit,
offset: $offset
) {
contribution
book {
id
title
subtitle
slug
release_date
headline
description
pages
rating
ratings_count
users_count
compilation
editions_count
cached_image
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
}
}
contributions_aggregate(
where: {
contributable_type: {_eq: "Book"},
book: {
canonical_id: {_is_null: true},
state: {_in: ["normalized", "normalizing"]}
}
}
) {
aggregate {
count
}
}
}
}
"""
HARDCOVER_STATUS_PREFIX = "status:" HARDCOVER_STATUS_PREFIX = "status:"
HARDCOVER_STATUSES: list[dict] = [ HARDCOVER_STATUSES: list[dict] = [
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"}, {"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
@@ -869,6 +939,7 @@ class HardcoverProvider(MetadataProvider):
label="Author", label="Author",
placeholder="Search author...", placeholder="Search author...",
description="Search by author name", description="Search by author name",
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author",
), ),
TextSearchField( TextSearchField(
key="title", key="title",
@@ -917,7 +988,7 @@ class HardcoverProvider(MetadataProvider):
Returns (query, fields, weights) tuple. Fields/weights are None for general search. Returns (query, fields, weights) tuple. Fields/weights are None for general search.
""" """
if author and not title and not series: if author and not title and not series:
return author, "author_names", "1" return author, None, None
if title and not author and not series: if title and not author and not series:
return title, "title,alternative_titles", "5,1" return title, "title,alternative_titles", "5,1"
if author and title and not series: if author and title and not series:
@@ -1210,13 +1281,14 @@ class HardcoverProvider(MetadataProvider):
if item is None: if item is None:
continue continue
author_id = coerce_int(item.get("id"), 0)
label = str(item.get("name") or "").strip() label = str(item.get("name") or "").strip()
normalized_label = label.casefold() normalized_label = label.casefold()
if not label or normalized_label in seen_labels: if author_id < 1 or not label or normalized_label in seen_labels:
continue continue
seen_labels.add(normalized_label) seen_labels.add(normalized_label)
options.append({"value": label, "label": label}) options.append({"value": f"id:{author_id}", "label": label})
return options return options
@@ -1527,6 +1599,72 @@ class HardcoverProvider(MetadataProvider):
has_more = offset + len(page_rows) < total_found has_more = offset + len(page_rows) < total_found
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more) return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
def _fetch_author_books_by_id(
self,
author_id: int,
page: int,
limit: int,
*,
exclude_compilations: bool,
exclude_unreleased: bool,
) -> SearchResult:
"""Fetch books for a selected Hardcover author."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
offset = (page - 1) * limit
result = self._execute_query(
AUTHOR_BOOKS_BY_ID_QUERY,
{"authorId": author_id, "limit": limit, "offset": offset},
)
if not result:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
author_items = result.get("authors", [])
if not isinstance(author_items, list) or not author_items:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
author_data = author_items[0] if isinstance(author_items[0], dict) else {}
contributions = (
author_data.get("contributions", []) if isinstance(author_data, dict) else []
)
aggregate = (
author_data.get("contributions_aggregate", {}) if isinstance(author_data, dict) else {}
)
total_found = coerce_int(
aggregate.get("aggregate", {}).get("count") if isinstance(aggregate, dict) else 0,
0,
)
today = datetime.now(UTC).date()
books: list[BookMetadata] = []
for row in contributions:
if not isinstance(row, dict):
continue
contribution = str(row.get("contribution") or "").strip()
if contribution and "author" not in contribution.casefold():
continue
book_data = row.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
if exclude_compilations and book_data.get("compilation"):
continue
release_date = _parse_release_date(book_data.get("release_date"))
if exclude_unreleased and (release_date is None or release_date.date() > today):
continue
try:
parsed_book = self._parse_book(book_data)
books.append(parsed_book)
except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc:
logger.debug(
"Failed to parse Hardcover author book for author_id=%s: %s",
author_id,
exc,
)
has_more = offset + len(contributions) < total_found
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
@cacheable(ttl=120, key_prefix="hardcover:user_lists") @cacheable(ttl=120, key_prefix="hardcover:user_lists")
def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]: def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]:
"""Return cached user lists keyed by Hardcover user id.""" """Return cached user lists keyed by Hardcover user id."""
@@ -2160,6 +2298,29 @@ class HardcoverProvider(MetadataProvider):
exclude_unreleased=exclude_unreleased, exclude_unreleased=exclude_unreleased,
) )
author_value_from_field = str(options.fields.get("author", "")).strip()
if author_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
author_id = self._parse_prefixed_int(author_value_from_field, "author id")
except ValueError:
logger.debug("Invalid Hardcover author id field value: %s", author_value_from_field)
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
return self._fetch_author_books_by_id(
author_id,
options.page,
options.limit,
exclude_compilations=exclude_compilations,
exclude_unreleased=exclude_unreleased,
)
# Handle ISBN search separately # Handle ISBN search separately
if options.search_type == SearchType.ISBN: if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query) result = self.search_by_isbn(options.query)
+2 -10
View File
@@ -7,7 +7,6 @@ import { useMountEffect } from '../hooks/useMountEffect';
import type { Book, ButtonStateInfo } from '../types'; import type { Book, ButtonStateInfo } from '../types';
import { isMetadataBook } from '../types'; import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader'; import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../utils/covers';
import { isUserCancelledError } from '../utils/errors'; import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown'; import { BookTargetDropdown } from './BookTargetDropdown';
@@ -137,10 +136,6 @@ export const DetailsModal = ({
const artworkMaxWidth = isSquareCover const artworkMaxWidth = isSquareCover
? 'min(45vw, 400px, calc(90vh - 220px))' ? 'min(45vw, 400px, calc(90vh - 220px))'
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))'; : 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: isSquareCover ? 640 : 480,
height: isSquareCover ? 640 : 720,
});
const additionalInfo = const additionalInfo =
book.info && Object.keys(book.info).length > 0 book.info && Object.keys(book.info).length > 0
? Object.entries(book.info).filter(([key]) => { ? Object.entries(book.info).filter(([key]) => {
@@ -206,17 +201,14 @@ export const DetailsModal = ({
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6"> <div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8"> <div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4"> <div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{optimizedPreview ? ( {book.preview ? (
<div <div
className="flex w-full items-center justify-center lg:h-full lg:max-w-none" className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }} style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
> >
<img <img
src={optimizedPreview} src={book.preview}
alt="Book cover" alt="Book cover"
width={isSquareCover ? 640 : 480}
height={isSquareCover ? 640 : 720}
decoding="async"
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg" className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
style={{ maxHeight: '100%', maxWidth: '100%' }} style={{ maxHeight: '100%', maxWidth: '100%' }}
/> />
+8 -22
View File
@@ -22,7 +22,6 @@ import type {
import { isMetadataBook } from '../types'; import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader'; import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getColorStyleFromHint } from '../utils/colorMaps'; import { getColorStyleFromHint } from '../utils/colorMaps';
import { getSizedCoverUrl } from '../utils/covers';
import { import {
LANGUAGE_OPTION_DEFAULT, LANGUAGE_OPTION_DEFAULT,
getLanguageFilterValues, getLanguageFilterValues,
@@ -211,9 +210,8 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const optimizedPreview = getSizedCoverUrl(preview, { width: 32, height: 48 });
if (!optimizedPreview || imageError) { if (!preview || imageError) {
return ( return (
<div <div
className="flex h-10 w-7 shrink-0 items-center justify-center rounded-sm bg-zinc-200 text-[7px] font-medium text-zinc-500 sm:h-12 sm:w-8 sm:text-[8px] dark:bg-zinc-700 dark:text-zinc-400" className="flex h-10 w-7 shrink-0 items-center justify-center rounded-sm bg-zinc-200 text-[7px] font-medium text-zinc-500 sm:h-12 sm:w-8 sm:text-[8px] dark:bg-zinc-700 dark:text-zinc-400"
@@ -230,13 +228,10 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
<div className="absolute inset-0 animate-pulse 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" /> <div className="absolute inset-0 animate-pulse 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" />
)} )}
<img <img
src={optimizedPreview} src={preview}
alt={title || 'Book cover'} alt={title || 'Book cover'}
className="h-full w-full object-cover object-top" className="h-full w-full object-cover object-top"
loading="lazy" loading="lazy"
decoding="async"
width={32}
height={48}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
@@ -1242,10 +1237,6 @@ const ReleaseModalSession = ({
} else if (book.series_name) { } else if (book.series_name) {
coverSizeClassName = 'h-[144px] w-24'; coverSizeClassName = 'h-[144px] w-24';
} }
const modalPreview = getSizedCoverUrl(book.preview, {
width: book.cover_aspect === 'square' ? 144 : 96,
height: 144,
});
let combinedFooterEbookMode = combinedEbookMode; let combinedFooterEbookMode = combinedEbookMode;
if (combinedPhase === 'ebook') { if (combinedPhase === 'ebook') {
@@ -1334,14 +1325,13 @@ const ReleaseModalSession = ({
{/* Mobile: static thumbnail always visible */} {/* Mobile: static thumbnail always visible */}
{!isRequestMode && ( {!isRequestMode && (
<div className="shrink-0 sm:hidden"> <div className="shrink-0 sm:hidden">
{modalPreview ? ( {book.preview ? (
<img <img
src={modalPreview} src={book.preview}
alt="" alt=""
width={book.cover_aspect === 'square' ? 68 : 46} width={book.cover_aspect === 'square' ? 68 : 46}
height={68} height={68}
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`} className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
decoding="async"
style={{ style={{
width: book.cover_aspect === 'square' ? 68 : 46, width: book.cover_aspect === 'square' ? 68 : 46,
height: 68, height: 68,
@@ -1375,14 +1365,13 @@ const ReleaseModalSession = ({
className="transition-opacity duration-300 ease-out" className="transition-opacity duration-300 ease-out"
style={{ opacity: showHeaderThumb ? 1 : 0 }} style={{ opacity: showHeaderThumb ? 1 : 0 }}
> >
{modalPreview ? ( {book.preview ? (
<img <img
src={modalPreview} src={book.preview}
alt="" alt=""
width={book.cover_aspect === 'square' ? 68 : 46} width={book.cover_aspect === 'square' ? 68 : 46}
height={68} height={68}
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`} className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
decoding="async"
style={{ style={{
width: book.cover_aspect === 'square' ? 68 : 46, width: book.cover_aspect === 'square' ? 68 : 46,
height: 68, height: 68,
@@ -1445,13 +1434,10 @@ const ReleaseModalSession = ({
ref={bookSummaryRef} ref={bookSummaryRef}
className="flex gap-4 border-b border-(--border-muted) px-5 py-4" className="flex gap-4 border-b border-(--border-muted) px-5 py-4"
> >
{modalPreview ? ( {book.preview ? (
<img <img
src={modalPreview} src={book.preview}
alt="Book cover" alt="Book cover"
width={book.cover_aspect === 'square' ? (book.series_name ? 144 : 120) : 96}
height={book.series_name ? 144 : 120}
decoding="async"
className={`hidden shrink-0 rounded-lg object-cover shadow-md sm:block ${coverAspectClassName} ${coverSizeClassName}`} className={`hidden shrink-0 rounded-lg object-cover shadow-md sm:block ${coverAspectClassName} ${coverSizeClassName}`}
/> />
) : ( ) : (
@@ -5,7 +5,6 @@ import { useEscapeKey } from '../hooks/useEscapeKey';
import { useMountEffect } from '../hooks/useMountEffect'; import { useMountEffect } from '../hooks/useMountEffect';
import { getMetadataBookInfo } from '../services/api'; import { getMetadataBookInfo } from '../services/api';
import type { CreateRequestPayload } from '../types'; import type { CreateRequestPayload } from '../types';
import { getSizedCoverUrl } from '../utils/covers';
import type { RequestConfirmationPreview } from '../utils/requestConfirmation'; import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
import { import {
applyRequestNoteToPayload, applyRequestNoteToPayload,
@@ -176,7 +175,6 @@ function RequestConfirmationModalSession({
const titleId = 'request-confirmation-modal-title'; const titleId = 'request-confirmation-modal-title';
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH); const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
const previewImage = getSizedCoverUrl(preview.preview, { width: 64, height: 96 });
const submit = async () => { const submit = async () => {
if (confirmDisabled) { if (confirmDisabled) {
@@ -242,15 +240,11 @@ function RequestConfirmationModalSession({
<div className="rounded-xl border border-(--border-muted) bg-(--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="flex gap-4">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)"> <div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
{previewImage ? ( {preview.preview ? (
<img <img
src={previewImage} src={preview.preview}
alt={`${preview.title} cover`} alt={`${preview.title} cover`}
className="h-full w-full object-cover object-top" className="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
width={64}
height={96}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-[10px] opacity-60"> <div className="flex h-full w-full items-center justify-center text-[10px] opacity-60">
@@ -3,7 +3,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { RequestRecord } from '../../types'; import type { RequestRecord } from '../../types';
import { withBasePath } from '../../utils/basePath'; import { withBasePath } from '../../utils/basePath';
import { getSizedCoverUrl } from '../../utils/covers';
import { Tooltip } from '../shared/Tooltip'; import { Tooltip } from '../shared/Tooltip';
import type { ActivityCardAction } from './activityCardModel'; import type { ActivityCardAction } from './activityCardModel';
import { buildActivityCardModel } from './activityCardModel'; import { buildActivityCardModel } from './activityCardModel';
@@ -500,7 +499,6 @@ export const ActivityCard = ({
const titleLineRef = useRef<HTMLParagraphElement | null>(null); const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({}); const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
const [titleOverflow, setTitleOverflow] = useState(false); const [titleOverflow, setTitleOverflow] = useState(false);
const previewImage = getSizedCoverUrl(item.preview, { width: 48, height: 72 });
useLayoutEffect(() => { useLayoutEffect(() => {
const measureBadgeOverflow = () => { const measureBadgeOverflow = () => {
@@ -711,15 +709,11 @@ export const ActivityCard = ({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Artwork */} {/* Artwork */}
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700"> <div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
{previewImage ? ( {item.preview ? (
<img <img
src={previewImage} src={item.preview}
alt={`${item.title} cover`} alt={`${item.title} cover`}
className="h-full w-full object-cover object-top" className="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
width={48}
height={72}
/> />
) : ( ) : (
<BookFallback /> <BookFallback />
@@ -3,7 +3,6 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext'; import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types'; import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown'; import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldBadges } from '../shared'; import { DisplayFieldBadges } from '../shared';
@@ -42,11 +41,6 @@ export const CardView = ({
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const targetProvider = book.provider; const targetProvider = book.provider;
const targetBookId = book.provider_id; const targetBookId = book.provider_id;
const isSquareCover = book.cover_aspect === 'square';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: 292,
height: isSquareCover ? 292 : 438,
});
let zIndex: number | undefined; let zIndex: number | undefined;
if (dropdownOpen) { if (dropdownOpen) {
zIndex = 20; zIndex = 20;
@@ -103,7 +97,7 @@ export const CardView = ({
#{book.series_position} #{book.series_position}
</div> </div>
)} )}
{optimizedPreview && !imageError ? ( {book.preview && !imageError ? (
<> <>
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0"> <div className="absolute inset-0">
@@ -111,13 +105,10 @@ export const CardView = ({
</div> </div>
)} )}
<img <img
src={optimizedPreview} src={book.preview}
alt={book.title || 'Book cover'} alt={book.title || 'Book cover'}
className="h-full w-full" className="h-full w-full"
loading="lazy" loading="lazy"
decoding="async"
width={292}
height={isSquareCover ? 292 : 438}
style={{ style={{
opacity: imageLoaded ? 1 : 0, opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out', transition: 'opacity 0.3s ease-in-out',
@@ -3,7 +3,6 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext'; import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types'; import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown'; import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared'; import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
@@ -45,11 +44,6 @@ export const CompactView = ({
const targetProvider = book.provider; const targetProvider = book.provider;
const targetBookId = book.provider_id; const targetBookId = book.provider_id;
const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone'); const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone');
const isSquareCover = book.cover_aspect === 'square';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: 120,
height: isSquareCover ? 120 : 180,
});
let zIndex: number | undefined; let zIndex: number | undefined;
if (dropdownOpen) { if (dropdownOpen) {
zIndex = 20; zIndex = 20;
@@ -103,7 +97,7 @@ export const CompactView = ({
#{book.series_position} #{book.series_position}
</div> </div>
)} )}
{optimizedPreview && !imageError ? ( {book.preview && !imageError ? (
<> <>
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0"> <div className="absolute inset-0">
@@ -111,13 +105,10 @@ export const CompactView = ({
</div> </div>
)} )}
<img <img
src={optimizedPreview} src={book.preview}
alt={book.title || 'Book cover'} alt={book.title || 'Book cover'}
className="h-full w-full" className="h-full w-full"
loading="lazy" loading="lazy"
decoding="async"
width={120}
height={isSquareCover ? 120 : 180}
style={{ style={{
opacity: imageLoaded ? 1 : 0, opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out', transition: 'opacity 0.3s ease-in-out',
@@ -4,7 +4,6 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo, DisplayField } from '../../types'; import type { Book, ButtonStateInfo, DisplayField } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps'; import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown'; import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared'; import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
@@ -48,12 +47,8 @@ const ListViewThumbnail = ({
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const isSquare = coverAspect === 'square'; 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'; const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14';
const optimizedPreview = getSizedCoverUrl(preview, {
width: isSquare ? 56 : 40,
height: isSquare ? 56 : 56,
});
if (!optimizedPreview || imageError) { if (!preview || imageError) {
return ( return (
<div <div
className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`} className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`}
@@ -72,13 +67,10 @@ const ListViewThumbnail = ({
<div className="absolute inset-0 animate-pulse 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" /> <div className="absolute inset-0 animate-pulse 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" />
)} )}
<img <img
src={optimizedPreview} src={preview}
alt={title || 'Book cover'} alt={title || 'Book cover'}
className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`} className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
loading="lazy" loading="lazy"
decoding="async"
width={isSquare ? 56 : 40}
height={isSquare ? 56 : 56}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
@@ -106,7 +106,6 @@ export const NamingTemplateField = ({
disabled={fieldDisabled} disabled={fieldDisabled}
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60" className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/> />
{boundField.description && <p className="text-xs opacity-60">{boundField.description}</p>}
</div> </div>
{(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && ( {(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && (
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSizedCoverUrl } from '../utils/covers';
describe('getSizedCoverUrl', () => {
it('adds size and format params to local cover proxy URLs', () => {
expect(
getSizedCoverUrl('/api/covers/book-1?url=abc', {
width: 120,
height: 180,
}),
).toBe('/api/covers/book-1?url=abc&w=120&h=180&format=webp');
});
it('leaves external preview URLs alone', () => {
expect(
getSizedCoverUrl('https://covers.example.com/book.jpg', {
width: 120,
height: 180,
}),
).toBe('https://covers.example.com/book.jpg');
});
it('preserves absolute proxy URLs', () => {
expect(
getSizedCoverUrl('https://bookrequest.example.com/api/covers/book-1?url=abc', {
width: 56,
height: 56,
format: 'png',
}),
).toBe('https://bookrequest.example.com/api/covers/book-1?url=abc&w=56&h=56&format=png');
});
});
@@ -2,11 +2,18 @@ import { describe, expect, it } from 'vitest';
import { import {
buildNamingTemplatePreview, buildNamingTemplatePreview,
NAMING_TEMPLATE_TOKENS,
renderNamingTemplate, renderNamingTemplate,
SAMPLE_NAMING_METADATA, SAMPLE_NAMING_METADATA,
} from '../utils/namingTemplatePreview'; } from '../utils/namingTemplatePreview';
describe('namingTemplatePreview', () => { describe('namingTemplatePreview', () => {
it('groups primary title with universal variables', () => {
expect(NAMING_TEMPLATE_TOKENS.find((token) => token.token === 'PrimaryTitle')?.group).toBe(
'Universal',
);
});
it('renders primary title in path previews', () => { it('renders primary title in path previews', () => {
const preview = buildNamingTemplatePreview( const preview = buildNamingTemplatePreview(
'{Author}/{Series/}{SeriesPosition - }{PrimaryTitle} ({Year})', '{Author}/{Series/}{SeriesPosition - }{PrimaryTitle} ({Year})',
-66
View File
@@ -1,66 +0,0 @@
const COVER_PROXY_PATH = '/api/covers/';
const LOCAL_URL_BASE = 'http://shelfmark.local';
const DEFAULT_COVER_FORMAT = 'webp';
const MAX_COVER_DIMENSION = 1024;
type CoverFormat = 'jpeg' | 'png' | 'webp';
interface SizedCoverUrlOptions {
width?: number;
height?: number;
format?: CoverFormat;
}
const normalizeDimension = (value?: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const rounded = Math.round(value);
if (rounded <= 0) {
return undefined;
}
return Math.min(rounded, MAX_COVER_DIMENSION);
};
export const getSizedCoverUrl = (
preview?: string,
{ width, height, format = DEFAULT_COVER_FORMAT }: SizedCoverUrlOptions = {},
) => {
if (!preview) {
return preview;
}
const isRelativeUrl = preview.startsWith('/');
let url: URL;
try {
url = new URL(preview, LOCAL_URL_BASE);
} catch {
return preview;
}
if (!url.pathname.includes(COVER_PROXY_PATH)) {
return preview;
}
const normalizedWidth = normalizeDimension(width);
const normalizedHeight = normalizeDimension(height);
if (normalizedWidth !== undefined) {
url.searchParams.set('w', String(normalizedWidth));
}
if (normalizedHeight !== undefined) {
url.searchParams.set('h', String(normalizedHeight));
}
if (format) {
url.searchParams.set('format', format);
}
const search = url.searchParams.toString();
const relativeUrl = `${url.pathname}${search ? `?${search}` : ''}${url.hash}`;
return isRelativeUrl ? relativeUrl : url.toString();
};
@@ -39,7 +39,7 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
label: 'Primary title', label: 'Primary title',
description: 'Title without the subtitle suffix', description: 'Title without the subtitle suffix',
value: 'The Hound of the Baskervilles', value: 'The Hound of the Baskervilles',
group: 'Core', group: 'Universal',
}, },
{ {
token: 'Year', token: 'Year',
+15
View File
@@ -156,6 +156,21 @@ def test_download_settings_naming_templates_use_wrapped_custom_component():
assert value_key not in fields_by_key assert value_key not in fields_by_key
def test_download_settings_naming_template_value_fields_are_registered():
import shelfmark.config.settings # noqa: F401
from shelfmark.core import settings_registry
field_map = settings_registry.get_settings_field_map(tab_name="downloads")
for value_key in (
"TEMPLATE_RENAME",
"TEMPLATE_ORGANIZE",
"TEMPLATE_AUDIOBOOK_RENAME",
"TEMPLATE_AUDIOBOOK_ORGANIZE",
):
assert value_key in field_map
def test_download_settings_naming_template_serialization_keeps_value_fields_hidden(): def test_download_settings_naming_template_serialization_keeps_value_fields_hidden():
from shelfmark.config.settings import download_settings from shelfmark.config.settings import download_settings
from shelfmark.core import settings_registry from shelfmark.core import settings_registry
+19
View File
@@ -38,3 +38,22 @@ def test_generated_env_docs_describe_mirror_lists_as_comma_separated_strings() -
"| `LIBGEN_MIRROR_URLS` | Mirrors are tried in the order you add them until one works. | " "| `LIBGEN_MIRROR_URLS` | Mirrors are tried in the order you add them until one works. | "
"string (comma-separated) | _empty list_ |" "string (comma-separated) | _empty list_ |"
) in docs ) in docs
def test_generated_env_docs_include_custom_component_value_fields() -> None:
docs = generate_env_docs()
for env_var in (
"TEMPLATE_RENAME",
"TEMPLATE_ORGANIZE",
"TEMPLATE_AUDIOBOOK_RENAME",
"TEMPLATE_AUDIOBOOK_ORGANIZE",
):
assert f"`{env_var}`" in docs
assert (
"| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: "
"{Author}, {Title}, {Year}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
) in docs
+1 -73
View File
@@ -1,25 +1,8 @@
"""Tests for targeted image cache safety and fetch fallbacks.""" """Tests for targeted image cache safety and fetch fallbacks."""
from io import BytesIO
import requests import requests
from PIL import Image
from shelfmark.core.image_cache import ( from shelfmark.core.image_cache import ImageCacheService
ImageCacheService,
build_variant_cache_id,
create_image_variant,
normalize_variant_dimension,
normalize_variant_format,
)
def _make_image_bytes(
*, width: int = 400, height: int = 600, image_format: str = "JPEG", color: str = "navy"
) -> bytes:
buffer = BytesIO()
Image.new("RGB", (width, height), color=color).save(buffer, format=image_format)
return buffer.getvalue()
def test_is_safe_url_rejects_invalid_ipv6_url() -> None: def test_is_safe_url_rejects_invalid_ipv6_url() -> None:
@@ -37,58 +20,3 @@ def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch
assert cache.fetch_and_cache("cover-1", "https://example.com/cover.jpg") is None assert cache.fetch_and_cache("cover-1", "https://example.com/cover.jpg") is None
assert "cover-1" not in cache._index assert "cover-1" not in cache._index
def test_create_image_variant_resizes_and_transcodes_to_webp() -> None:
variant = create_image_variant(
_make_image_bytes(),
width=120,
height=180,
image_format="webp",
)
assert variant is not None
variant_bytes, content_type = variant
assert content_type == "image/webp"
with Image.open(BytesIO(variant_bytes)) as image:
assert image.size == (120, 180)
def test_create_image_variant_preserves_aspect_ratio_for_single_dimension() -> None:
variant = create_image_variant(
_make_image_bytes(),
width=120,
image_format="jpeg",
)
assert variant is not None
variant_bytes, content_type = variant
assert content_type == "image/jpeg"
with Image.open(BytesIO(variant_bytes)) as image:
assert image.size == (120, 180)
def test_create_image_variant_returns_none_when_no_change_needed() -> None:
image_bytes = _make_image_bytes(image_format="WEBP")
assert create_image_variant(image_bytes, image_format="webp") is None
def test_variant_helpers_normalize_requested_variant_values() -> None:
assert normalize_variant_dimension("240") == 240
assert normalize_variant_dimension("0") is None
assert normalize_variant_dimension("99999") == 1024
assert normalize_variant_format("jpg") == "jpeg"
assert normalize_variant_format("weBp") == "webp"
assert normalize_variant_format("gif") is None
assert (
build_variant_cache_id(
"cover-123",
width=120,
height=180,
image_format="webp",
)
== "cover-123__w120_h180_fwebp"
)
@@ -2,11 +2,13 @@ from shelfmark.metadata_providers.hardcover import HardcoverProvider
class TestHardcoverFieldOptions: class TestHardcoverFieldOptions:
def test_search_fields_enable_typeahead_for_series_only(self): def test_search_fields_enable_typeahead_for_author_and_series(self):
provider = HardcoverProvider(api_key="test-token") provider = HardcoverProvider(api_key="test-token")
fields_by_key = {field.key: field for field in provider.search_fields} fields_by_key = {field.key: field for field in provider.search_fields}
assert fields_by_key["author"].suggestions_endpoint is None assert fields_by_key["author"].suggestions_endpoint == (
"/api/metadata/field-options?provider=hardcover&field=author"
)
assert fields_by_key["title"].suggestions_endpoint is None assert fields_by_key["title"].suggestions_endpoint is None
assert fields_by_key["series"].suggestions_endpoint == ( assert fields_by_key["series"].suggestions_endpoint == (
"/api/metadata/field-options?provider=hardcover&field=series" "/api/metadata/field-options?provider=hardcover&field=series"
@@ -25,9 +27,9 @@ class TestHardcoverFieldOptions:
"search": { "search": {
"results": { "results": {
"hits": [ "hits": [
{"document": {"name": "Brandon Sanderson"}}, {"document": {"id": 1, "name": "Brandon Sanderson"}},
{"document": {"name": "Brandon Sanderson"}}, {"document": {"id": 1, "name": "Brandon Sanderson"}},
{"document": {"name": "Brian Sanderson"}}, {"document": {"id": 2, "name": "Brian Sanderson"}},
], ],
"found": 3, "found": 3,
} }
@@ -39,8 +41,8 @@ class TestHardcoverFieldOptions:
options = provider.get_search_field_options("author", query="sand") options = provider.get_search_field_options("author", query="sand")
assert options == [ assert options == [
{"value": "Brandon Sanderson", "label": "Brandon Sanderson"}, {"value": "id:1", "label": "Brandon Sanderson"},
{"value": "Brian Sanderson", "label": "Brian Sanderson"}, {"value": "id:2", "label": "Brian Sanderson"},
] ]
assert captured["variables"] == { assert captured["variables"] == {
"query": "sand", "query": "sand",
+125 -1
View File
@@ -1,4 +1,5 @@
from shelfmark.metadata_providers.hardcover import _simplify_author_for_search from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult
from shelfmark.metadata_providers.hardcover import HardcoverProvider, _simplify_author_for_search
class TestHardcoverSimplifyAuthorForSearch: class TestHardcoverSimplifyAuthorForSearch:
@@ -13,3 +14,126 @@ class TestHardcoverSimplifyAuthorForSearch:
def test_returns_none_when_no_change(self): def test_returns_none_when_no_change(self):
assert _simplify_author_for_search("Frank Herbert") is None assert _simplify_author_for_search("Frank Herbert") is None
class TestHardcoverAuthorSearch:
def test_author_text_search_uses_default_book_search_fields(self):
provider = HardcoverProvider(api_key="test-token")
assert provider._build_search_params("", "Stephen King", "", "") == (
"Stephen King",
None,
None,
)
def test_search_paginated_uses_selected_author_id(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
expected = SearchResult(books=[], page=2, total_found=14, has_more=True)
captured: dict[str, int] = {}
monkeypatch.setattr(
"shelfmark.metadata_providers.hardcover.app_config.get",
lambda key, default=None: {
"HARDCOVER_EXCLUDE_COMPILATIONS": True,
"HARDCOVER_EXCLUDE_UNRELEASED": False,
}.get(key, default),
)
def fake_fetch(
author_id: int,
page: int,
limit: int,
*,
exclude_compilations: bool,
exclude_unreleased: bool,
) -> SearchResult:
captured["author_id"] = author_id
captured["page"] = page
captured["limit"] = limit
captured["exclude_compilations"] = int(exclude_compilations)
captured["exclude_unreleased"] = int(exclude_unreleased)
return expected
monkeypatch.setattr(provider, "_fetch_author_books_by_id", fake_fetch)
result = provider.search_paginated(
MetadataSearchOptions(
query="",
page=2,
limit=20,
fields={"author": "id:42"},
)
)
assert result == expected
assert captured == {
"author_id": 42,
"page": 2,
"limit": 20,
"exclude_compilations": 1,
"exclude_unreleased": 0,
}
def test_fetch_author_books_by_id_returns_books(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
captured: dict[str, object] = {}
monkeypatch.setattr(
provider,
"_execute_query",
lambda query, variables: (
captured.update({"query": query, "variables": variables})
or {
"authors": [
{
"name": "Stephen King",
"contributions": [
{
"contribution": "Author, Narrator",
"book": {
"id": 1,
"title": "The Shining",
"subtitle": None,
"slug": "the-shining",
"release_date": "1977-01-28",
"headline": None,
"description": None,
"pages": 447,
"rating": 4.3,
"ratings_count": 1000,
"users_count": 2000,
"compilation": False,
"editions_count": 20,
"cached_image": {},
"cached_contributors": [{"name": "Stephen King"}],
"contributions": [],
"featured_book_series": None,
},
}
],
"contributions_aggregate": {"aggregate": {"count": 1}},
}
]
}
),
)
result = provider._fetch_author_books_by_id(
42,
page=1,
limit=20,
exclude_compilations=True,
exclude_unreleased=True,
)
assert "contributions(" in str(captured["query"])
assert (
"contribution:"
not in str(captured["query"]).split("contributions(", 1)[1].split(") {", 1)[0]
)
assert "canonical_id: {_is_null: true}" in str(captured["query"])
assert captured["variables"] == {"authorId": 42, "limit": 20, "offset": 0}
assert result.total_found == 1
assert result.has_more is False
assert [book.title for book in result.books] == ["The Shining"]
assert result.books[0].authors == ["Stephen King"]
+1 -1
View File
@@ -202,7 +202,7 @@ while true; do
exit 1 exit 1
fi fi
CURRENT_LOG=$(tail -n 1 /var/log/tor/notices.log 2>/dev/null) CURRENT_LOG=$(tail -n 1 /var/log/tor/notices.log 2>/dev/null || true)
printf "\r\033[K[%ds] %s" "$ELAPSED" "$CURRENT_LOG" printf "\r\033[K[%ds] %s" "$ELAPSED" "$CURRENT_LOG"
sleep 1 sleep 1
done done
Generated
-35
View File
@@ -718,39 +718,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/fe/53ac0cd932db5dcaf55961bc7cb7afdca8d80d8cc7406ed661f0c7dc111a/pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8", size = 21969, upload-time = "2026-01-14T03:10:27.062Z" }, { url = "https://files.pythonhosted.org/packages/51/fe/53ac0cd932db5dcaf55961bc7cb7afdca8d80d8cc7406ed661f0c7dc111a/pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8", size = 21969, upload-time = "2026-01-14T03:10:27.062Z" },
] ]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]] [[package]]
name = "pip" name = "pip"
version = "26.0.1" version = "26.0.1"
@@ -1373,7 +1340,6 @@ dependencies = [
{ name = "gevent" }, { name = "gevent" },
{ name = "gevent-websocket" }, { name = "gevent-websocket" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "pillow" },
{ name = "psutil" }, { name = "psutil" },
{ name = "python-socketio" }, { name = "python-socketio" },
{ name = "qbittorrent-api" }, { name = "qbittorrent-api" },
@@ -1416,7 +1382,6 @@ requires-dist = [
{ name = "gevent" }, { name = "gevent" },
{ name = "gevent-websocket" }, { name = "gevent-websocket" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "psutil" }, { name = "psutil" },
{ name = "pyautogui", marker = "extra == 'browser'" }, { name = "pyautogui", marker = "extra == 'browser'" },
{ name = "python-socketio" }, { name = "python-socketio" },