Various requested small features (#741)

- Added torrent removal option
- Pass Prowlarr seedtimes to download clients (excluding rTorrent)
- Split default release source option by content type
- Split download to browser option by content type
- Add "hide links" option
This commit is contained in:
Alex
2026-03-12 17:36:07 +00:00
committed by GitHub
parent 7f79da11e6
commit 3d72f9e258
66 changed files with 1064 additions and 266 deletions
+43 -11
View File
@@ -186,9 +186,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 +215,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 +232,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 +242,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 +299,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 +582,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 +1270,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 +1512,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**
+95 -9
View File
@@ -185,6 +185,23 @@ _AUDIOBOOK_FORMAT_OPTIONS = [
{"value": "rar", "label": "RAR"},
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
{
"value": "book",
"label": "Books",
"description": "Automatically download completed book files to this browser.",
},
{
"value": "audiobook",
"label": "Audiobooks",
"description": "Automatically download completed audiobook files to this browser.",
},
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS
}
def _get_metadata_provider_options():
"""Build metadata provider options dynamically from enabled providers only."""
@@ -210,17 +227,30 @@ def _get_metadata_provider_options_with_none():
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
def _get_release_source_options():
"""Build release source options dynamically from registered sources."""
def _get_release_source_options_for_content_type(content_type: str):
"""Build release source options dynamically for a specific content type."""
from shelfmark.release_sources import list_available_sources
return [
{"value": source["name"], "label": source["display_name"]}
for source in list_available_sources()
if source.get("can_be_default", True)
and content_type in source.get("supported_content_types", ["ebook", "audiobook"])
]
def _get_book_release_source_options():
"""Build default release source options for book searches."""
return _get_release_source_options_for_content_type("ebook")
def _get_audiobook_release_source_options():
"""Build default release source options for audiobook searches."""
return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type(
"audiobook"
)
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
@@ -429,6 +459,15 @@ 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,
),
HeadingField(
key="universal_mode_heading",
title="Universal Mode Settings",
@@ -455,13 +494,22 @@ def search_mode_settings():
),
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 +667,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":
@@ -1106,11 +1189,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",
+18 -5
View File
@@ -76,6 +76,7 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDI
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"SEARCH_MODE",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
@@ -117,6 +118,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,16 +202,16 @@ 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
+29
View File
@@ -78,10 +78,39 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
}:
valid[key] = normalized_search_value
continue
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
if not isinstance(value, list):
errors.append(f"Invalid value for {key}: must be a list")
continue
candidate_values = [
str(entry).strip().lower()
for entry in value
if str(entry).strip()
]
normalized_values: list[str] = []
has_invalid_value = False
for entry in candidate_values:
if entry not in {"book", "audiobook"}:
errors.append(
f"Invalid value for {key}: unsupported content type '{entry}'"
)
has_invalid_value = True
continue
if entry not in normalized_values:
normalized_values.append(entry)
if has_invalid_value:
continue
valid[key] = normalized_values
continue
valid[key] = value
return valid, errors
+52
View File
@@ -481,6 +481,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 +692,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
+24 -12
View File
@@ -38,6 +38,8 @@ class DownloadRequest:
protocol: str
release_name: str
expected_hash: Optional[str]
seeding_time_limit: Optional[int] = None # minutes
ratio_limit: Optional[float] = None
def _diagnose_path_issue(path: str) -> str:
@@ -140,20 +142,28 @@ class ExternalClientHandler(DownloadHandler, ABC):
return
client, download_id, protocol = client_ref
if protocol != "usenet":
return
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
if protocol == "usenet":
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
)
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
)
elif protocol == "torrent":
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
return
try:
client.remove(download_id, delete_files=False)
except Exception as e:
logger.warning(
f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}"
)
def _remove_usenet_download(
self,
@@ -553,6 +563,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
logger.error(f"Failed to add to {client.name}: {e}")
+9
View File
@@ -237,6 +237,15 @@ class DelugeClient(DownloadClient):
if self._download_dir:
options["download_location"] = self._download_dir
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
options["seed_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
options["stop_at_ratio"] = float(ratio_limit)
options["stop_at_ratio_enabled"] = True
if torrent_info.is_magnet:
magnet_url = torrent_info.magnet_url or url
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
+59 -27
View File
@@ -61,6 +61,23 @@ def _normalize_tags(raw_tags: object) -> list[str]:
return tags
def _normalize_add_result(raw_result: object) -> str:
"""Normalize qBittorrent add responses to a comparable string."""
if raw_result is None:
return ""
if isinstance(raw_result, bytes):
return raw_result.decode("utf-8", errors="replace").strip()
return str(raw_result).strip()
def _is_explicit_add_failure(raw_result: object) -> bool:
"""Detect add responses that clearly indicate failure."""
normalized = _normalize_add_result(raw_result).rstrip(".").lower()
return normalized in {"fail", "fails", "error", "errors"}
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
@@ -291,13 +308,16 @@ class QBittorrentClient(DownloadClient):
tags = self._tags
# Ensure category exists (may already exist, which is fine)
try:
self._client.torrents_create_category(name=category)
except Exception as e:
# Conflict409Error means category exists - that's expected
# Log other errors but continue since download may still work
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
if category:
try:
self._client.torrents_create_category(name=category)
except Exception as e:
# Conflict409Error means category exists - that's expected
# Log other errors but continue since download may still work
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(
f"Could not create category '{category}': {type(e).__name__}: {e}"
)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
expected_hash = torrent_info.info_hash
@@ -305,14 +325,23 @@ class QBittorrentClient(DownloadClient):
# Add the torrent - use file content if we have it, otherwise URL
add_kwargs = {
"category": category,
"rename": name,
}
if category:
add_kwargs["category"] = category
if self._download_dir:
add_kwargs["save_path"] = self._download_dir
if tags:
add_kwargs["tags"] = ",".join(tags)
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
add_kwargs["seeding_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
add_kwargs["ratio_limit"] = float(ratio_limit)
if torrent_data:
result = self._client.torrents_add(
torrent_files=torrent_data,
@@ -326,29 +355,32 @@ class QBittorrentClient(DownloadClient):
**add_kwargs,
)
logger.debug(f"qBittorrent add result: {result}")
result_text = _normalize_add_result(result)
logger.debug(f"qBittorrent add result: {result_text}")
if result == "Ok.":
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
# Wait for torrent to appear in client.
# Use `/torrents/properties?hash=` rather than relying on `torrents/info`
# listing being immediately consistent.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug(f"qBittorrent add_download: {error}")
if loaded:
logger.info(f"Added torrent: {expected_hash}")
return expected_hash.lower()
time.sleep(0.5)
if _is_explicit_add_failure(result):
raise Exception(f"Failed to add torrent: {result_text}")
# Client said Ok, trust it
logger.warning(f"Torrent not yet visible, returning expected hash")
return expected_hash
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
# the torrent becomes visible over trusting the response body alone.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug(f"qBittorrent add_download: {error}")
if loaded:
logger.info(f"Added torrent: {expected_hash}")
return expected_hash.lower()
time.sleep(0.5)
raise Exception(f"Failed to add torrent: {result}")
logger.warning(
"Torrent add was not confirmed within the visibility grace period "
f"(response={result_text or '<empty>'}), returning expected hash"
)
return expected_hash
except Exception as e:
logger.error(f"qBittorrent add failed: {e}")
raise
+11 -1
View File
@@ -610,7 +610,17 @@ def prowlarr_clients_settings():
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
# Note: Torrent client download path must be mounted identically in both containers.
# Torrents are always copied (not moved) to preserve seeding capability.
SelectField(
key="PROWLARR_TORRENT_ACTION",
label="Torrent Completion Action",
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
options=[
{"value": "keep", "label": "Keep"},
{"value": "remove", "label": "Remove"},
],
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
# --- Usenet Client Selection ---
HeadingField(
@@ -188,6 +188,22 @@ class TransmissionClient(DownloadClient):
torrent_hash = torrent.hashString.lower()
logger.info(f"Added torrent to Transmission: {torrent_hash}")
# Apply per-torrent seeding limits from indexer
seed_kwargs = {}
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
seed_kwargs["seed_idle_limit"] = int(seeding_time_limit)
seed_kwargs["seed_idle_mode"] = 1 # per-torrent
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
seed_kwargs["seed_ratio_limit"] = float(ratio_limit)
seed_kwargs["seed_ratio_mode"] = 1 # per-torrent
if seed_kwargs:
try:
self._client.change_torrent(ids=torrent_hash, **seed_kwargs)
except Exception as e:
logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}")
return torrent_hash
except Exception as e:
+41 -2
View File
@@ -88,6 +88,7 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
_VERIFY_IO_WAIT_SECONDS = 3.0
_PUBLISH_VERIFY_RETRY_SECONDS = 0.25
def _verify_transfer_size(
@@ -119,6 +120,44 @@ def _verify_transfer_size(
)
def _is_stale_handle_error(error: Exception) -> bool:
return isinstance(error, OSError) and error.errno == getattr(errno, "ESTALE", 116)
def _verify_published_file(
dest: Path,
expected_size: int,
action: str,
) -> None:
"""Best-effort verify after publishing a temp file into place.
The temp file was already verified before publish. Some NFS mounts can report
a transient stale handle immediately after `os.replace()` makes the final path
visible, so retry once and then trust the successful publish instead of
turning the handoff into a false failure.
"""
try:
_verify_transfer_size(dest, expected_size, action)
return
except OSError as error:
if not _is_stale_handle_error(error):
raise
time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS)
try:
_verify_transfer_size(dest, expected_size, action)
except OSError as retry_error:
if not _is_stale_handle_error(retry_error):
raise
logger.warning(
"Skipping post-publish verification for %s after stale handle on %s: %s",
action,
dest,
retry_error,
)
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
"""Write data to a file with atomic collision detection.
@@ -417,7 +456,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
continue
try:
_verify_transfer_size(try_path, expected_size, "move")
_verify_published_file(try_path, expected_size, "move")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
raise
@@ -594,7 +633,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
continue
try:
_verify_transfer_size(try_path, expected_size, "copy")
_verify_published_file(try_path, expected_size, "copy")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
raise
+12 -1
View File
@@ -981,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",
"",
@@ -1007,9 +1012,15 @@ 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),
"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),
"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
@@ -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:
+14 -1
View File
@@ -556,6 +556,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 || {};
@@ -592,7 +602,7 @@ function App() {
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 = '';
@@ -2232,6 +2242,7 @@ function App() {
? getUniversalActionButtonState(selectedBook.id)
: getDirectActionButtonState(selectedBook.id)
}
showReleaseSourceLinks={config?.show_release_source_links !== false}
/>
)}
@@ -2255,9 +2266,11 @@ 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}
/>
)}
@@ -194,7 +194,7 @@ export const AdvancedFilters = ({
return renderWrapper ? (
renderWrapper(settingsForm)
) : (
<div className="w-full border-b-hairline pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">{settingsForm}</div>
</div>
);
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border-hairline border-(--border-muted) shadow-2xl
border border-(--border-muted) shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -82,7 +82,7 @@ export const ConfigSetupBanner = ({
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b-hairline border-(--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>
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border-hairline border-(--border-muted)">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--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,13 +139,13 @@ export const ConfigSetupBanner = ({
</div>
{/* Footer */}
<div className="px-5 py-4 border-t-hairline border-(--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-(--bg-soft) border-hairline border-(--border-muted)
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
+10 -8
View File
@@ -12,6 +12,7 @@ interface DetailsModalProps {
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
showReleaseSourceLinks?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
@@ -22,6 +23,7 @@ export const DetailsModal = ({
onFindDownloads,
onSearchSeries,
buttonState,
showReleaseSourceLinks = true,
onShowToast,
}: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
@@ -91,6 +93,7 @@ export const DetailsModal = ({
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
const isMetadata = isMetadataBook(book);
const showBookSourceLink = Boolean(book.source_url) && (isMetadata || showReleaseSourceLinks);
const metadataActionText =
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
? 'Find Downloads'
@@ -135,7 +138,7 @@ export const DetailsModal = ({
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border-hairline border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--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';
@@ -152,8 +155,8 @@ export const DetailsModal = ({
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-hairline 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-hairline border-(--border-muted) bg-(--bg) sm:bg-(--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 +201,7 @@ export const DetailsModal = ({
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border-hairline border-dashed border-(--border-muted) bg-(--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
@@ -329,12 +332,11 @@ export const DetailsModal = ({
</div>
<footer
className="border-t-hairline border-(--border-muted) bg-(--bg) sm:bg-(--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 +354,7 @@ export const DetailsModal = ({
</svg>
</a>
)}
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
<div className="flex w-full flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
{hasBookTargets && book.provider_id && (
<BookTargetDropdown
provider={book.provider!}
+2 -2
View File
@@ -184,7 +184,7 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border-hairline 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}`}
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={{
color: 'var(--text)',
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
@@ -222,7 +222,7 @@ export const Dropdown = ({
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border-hairline z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
+4 -4
View File
@@ -399,7 +399,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-hairline border-(--bg)"
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-(--bg)"
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
/>
)}
@@ -408,7 +408,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* Dropdown Menu */}
{(isDropdownOpen || isClosing) && (
<div
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border-hairline z-50 ${
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border z-50 ${
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
}`}
style={{
@@ -550,7 +550,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* User Footer */}
{authRequired && isAuthenticated && username && (
<div
className="border-t-hairline"
className="border-t"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="px-4 py-3 flex items-center gap-2.5">
@@ -581,7 +581,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{isAdmin && onActingAsUserChange && (
<div
className="border-t-hairline px-4 py-3 space-y-2"
className="border-t px-4 py-3 space-y-2"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="text-xs font-medium uppercase tracking-wide opacity-70">
+4 -4
View File
@@ -112,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-hairline focus:outline-hidden 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)',
@@ -141,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-hairline focus:outline-hidden 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)',
@@ -283,7 +283,7 @@ export const LoginForm = ({
{!hideLocalAuth && (
<>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
@@ -291,7 +291,7 @@ export const LoginForm = ({
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
{showPasswordLogin && (
@@ -93,13 +93,13 @@ export const OnBehalfConfirmationModal = ({
/>
<div
className={`relative w-full max-w-lg rounded-xl border-hairline border-(--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-hairline border-(--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>
@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({
<p className="text-sm opacity-90">
This download will use {actingAsName}&apos;s output preferences and destination settings.
</p>
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--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 wrap-break-word">{itemTitle}</p>
</div>
</div>
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--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-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--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>
@@ -390,7 +390,7 @@ export const OnboardingModal = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border-hairline border-(--border-muted)
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
@@ -416,7 +416,7 @@ export const OnboardingModal = ({
{/* Modal */}
<div
className={`relative w-full max-w-xl rounded-xl
border-hairline border-(--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-hairline border-(--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}
@@ -481,7 +481,7 @@ export const OnboardingModal = ({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t-hairline border-(--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,7 +499,7 @@ export const OnboardingModal = ({
onClick={handleBack}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-(--bg-soft) border-hairline border-(--border-muted)
bg-(--bg-soft) border border-(--border-muted)
hover:bg-(--hover-surface) transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
+87 -49
View File
@@ -14,6 +14,7 @@ import {
SearchStatusData,
ContentType,
RequestPolicyMode,
isMetadataBook,
} from '../types';
import { getReleases, getReleaseSources } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
@@ -93,10 +94,12 @@ interface ReleaseModalProps {
defaultLanguages: string[];
bookLanguages: Language[];
currentStatus: StatusData;
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
defaultReleaseSource?: string; // Default book tab to show (e.g., 'direct_download')
defaultAudiobookReleaseSource?: string; // Default audiobook tab to show
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
defaultShowManualQuery?: boolean;
isRequestMode?: boolean;
showReleaseSourceLinks?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
@@ -154,7 +157,7 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
}
return (
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded-sm overflow-hidden bg-zinc-100 dark:bg-zinc-800 border-hairline border-white/40 dark:border-zinc-700/70 shrink-0">
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded-sm overflow-hidden bg-zinc-100 dark:bg-zinc-800 border border-white/40 dark:border-zinc-700/70 shrink-0">
{!imageLoaded && (
<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" />
)}
@@ -224,6 +227,7 @@ const ReleaseRow = ({
gridTemplate,
leadingCell,
onlineServers,
showReleaseSourceLinks,
}: {
release: Release;
index: number;
@@ -233,6 +237,7 @@ const ReleaseRow = ({
gridTemplate: string;
leadingCell?: LeadingCellConfig;
onlineServers?: string[];
showReleaseSourceLinks: boolean;
}) => {
const author = release.extra?.author as string | undefined;
@@ -271,7 +276,7 @@ const ReleaseRow = ({
{/* Fixed: Title and author */}
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-2" title={release.title}>
{release.info_url ? (
{showReleaseSourceLinks && release.info_url ? (
<a
href={release.info_url}
target="_blank"
@@ -318,7 +323,7 @@ const ReleaseRow = ({
<div className="min-w-0">
{/* Title and author on same line */}
<p className="text-sm leading-tight line-clamp-2" title={release.title}>
{release.info_url ? (
{showReleaseSourceLinks && release.info_url ? (
<a
href={release.info_url}
target="_blank"
@@ -529,15 +534,20 @@ export const ReleaseModal = ({
bookLanguages,
currentStatus,
defaultReleaseSource,
defaultAudiobookReleaseSource,
onSearchSeries,
defaultShowManualQuery = false,
isRequestMode = false,
showReleaseSourceLinks = true,
onShowToast,
}: ReleaseModalProps) => {
// Use audiobook formats when in audiobook mode
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
? supportedAudiobookFormats
: supportedFormats;
const preferredDefaultReleaseSource = contentType === 'audiobook'
? (defaultAudiobookReleaseSource || defaultReleaseSource)
: defaultReleaseSource;
const [isClosing, setIsClosing] = useState(false);
const [isRequestingBook, setIsRequestingBook] = useState(false);
@@ -790,15 +800,15 @@ export const ReleaseModal = ({
return;
}
// Set active tab: prefer defaultReleaseSource if enabled and supports content type
// Set active tab: prefer the configured default source if enabled and supports content type
if (supportedSources.length > 0) {
const enabledSources = supportedSources.filter(s => s.enabled);
const defaultIsEnabled = defaultReleaseSource &&
enabledSources.some(s => s.name === defaultReleaseSource);
const defaultIsEnabled = preferredDefaultReleaseSource &&
enabledSources.some(s => s.name === preferredDefaultReleaseSource);
let defaultSource: string;
if (defaultIsEnabled) {
defaultSource = defaultReleaseSource;
defaultSource = preferredDefaultReleaseSource;
} else if (enabledSources.length > 0) {
defaultSource = enabledSources[0].name;
} else {
@@ -822,7 +832,7 @@ export const ReleaseModal = ({
};
fetchSources();
}, [book, defaultReleaseSource, contentType]);
}, [book, preferredDefaultReleaseSource, contentType]);
// Fetch releases when active tab changes (with caching)
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
@@ -948,16 +958,16 @@ export const ReleaseModal = ({
});
// Sort so default source appears first
if (defaultReleaseSource) {
if (preferredDefaultReleaseSource) {
enabledTabs.sort((a, b) => {
if (a.name === defaultReleaseSource) return -1;
if (b.name === defaultReleaseSource) return 1;
if (a.name === preferredDefaultReleaseSource) return -1;
if (b.name === preferredDefaultReleaseSource) return 1;
return 0;
});
}
return enabledTabs;
}, [availableSources, book?.provider, defaultReleaseSource, contentType]);
}, [availableSources, book?.provider, preferredDefaultReleaseSource, contentType]);
// Update tab indicator position when active tab changes
useEffect(() => {
@@ -1276,6 +1286,7 @@ export const ReleaseModal = ({
const providerDisplay =
book.provider_display_name ||
(book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : 'Unknown');
const showBookSourceLink = Boolean(book.source_url) && (isMetadataBook(book) || showReleaseSourceLinks);
const currentTabLoading = loadingBySource[activeTab] ?? false;
const currentTabError = errorBySource[activeTab] ?? null;
@@ -1297,13 +1308,35 @@ export const ReleaseModal = ({
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-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
<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 */}
<header className="flex items-start gap-3 border-b-hairline border-(--border-muted) px-5 py-4">
{/* Animated thumbnail that appears when scrolling */}
<header className="flex items-start gap-3 border-b border-(--border-muted) px-5 py-4">
{/* Mobile: static thumbnail always visible */}
{!isRequestMode && (
<div className="sm:hidden shrink-0">
{book.preview ? (
<img
src={book.preview}
alt=""
width={46}
height={68}
className="rounded-sm shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
/>
) : (
<div
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
style={{ width: 46, height: 68, minWidth: 46 }}
>
No cover
</div>
)}
</div>
)}
{/* Desktop: animated thumbnail that appears when scrolling */}
{!isRequestMode && (
<div
className="shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
className="hidden sm:block shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
marginRight: showHeaderThumb ? 0 : -12,
@@ -1324,7 +1357,7 @@ export const ReleaseModal = ({
/>
) : (
<div
className="rounded-sm border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
style={{ width: 46, height: 68, minWidth: 46 }}
>
No cover
@@ -1364,15 +1397,15 @@ export const ReleaseModal = ({
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Book summary - scrolls with content */}
{!isRequestMode && (
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b-hairline border-(--border-muted)">
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-(--border-muted)">
{book.preview ? (
<img
src={book.preview}
alt="Book cover"
className={`rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
className={`hidden sm:block rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
/>
) : (
<div className={`rounded-lg border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
<div className={`hidden sm:flex rounded-lg border border-dashed border-(--border-muted) bg-(--bg)/60 items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
No cover
</div>
)}
@@ -1467,7 +1500,7 @@ export const ReleaseModal = ({
ISBN: {book.isbn_13 || book.isbn_10}
</span>
)}
{book.source_url && (
{showBookSourceLink && (
<a
href={book.source_url}
target="_blank"
@@ -1480,28 +1513,32 @@ export const ReleaseModal = ({
</svg>
</a>
)}
{onRequestBook && (
<button
type="button"
onClick={() => {
void handleRequestBook();
}}
disabled={isRequestingBook}
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 disabled:cursor-not-allowed disabled:opacity-60"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="pill"
/>
{(onRequestBook || bookSupportsTargets(book)) && (
<span className="inline-flex items-center gap-3">
{onRequestBook && (
<button
type="button"
onClick={() => {
void handleRequestBook();
}}
disabled={isRequestingBook}
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 disabled:cursor-not-allowed disabled:opacity-60"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="pill"
/>
)}
</span>
)}
</div>
</div>
@@ -1509,7 +1546,7 @@ export const ReleaseModal = ({
)}
{/* Source tabs + filters - sticky within scroll container */}
<div className="sticky top-0 z-10 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
<div className="sticky top-0 z-10 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
{sourcesLoading ? (
<div className="flex gap-1 px-5 py-2">
<div className="h-10 w-32 animate-pulse bg-zinc-200 dark:bg-zinc-700 rounded-sm" />
@@ -1654,7 +1691,7 @@ export const ReleaseModal = ({
{availableFormats.length > 1 && (
<>
{allSortOptions.length > 0 && (
<div className="mx-2 my-1 border-t-hairline border-zinc-200 dark:border-zinc-700" />
<div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
)}
<button
type="button"
@@ -1852,7 +1889,7 @@ export const ReleaseModal = ({
{/* Manual query panel (below source tabs) */}
{showManualQuery && (
<div className="px-5 py-3 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
<div className="px-5 py-3 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
<form
className="flex items-center gap-2"
onSubmit={async (e) => {
@@ -1907,7 +1944,7 @@ export const ReleaseModal = ({
value={manualQuery}
onChange={(e) => setManualQuery(e.target.value)}
placeholder="Type a custom search query (overrides all sources)"
className="w-full px-3 py-2 text-sm rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-(--text)"
className="w-full px-3 py-2 text-sm rounded-lg border border-(--border-muted) bg-(--bg) text-(--text)"
/>
<button
type="submit"
@@ -1981,6 +2018,7 @@ export const ReleaseModal = ({
gridTemplate={columnConfig.grid_template}
leadingCell={columnConfig.leading_cell}
onlineServers={columnConfig.online_servers}
showReleaseSourceLinks={showReleaseSourceLinks}
/>
))}
</div>
@@ -2019,7 +2057,7 @@ export const ReleaseModal = ({
{/* Sticky search status indicator - stays at bottom of visible scroll area */}
{searchStatus && searchStatus.source === activeTab && currentTabLoading && (
<div className="sticky bottom-0 z-10 flex items-center justify-center pointer-events-none pb-4 pt-2">
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-(--bg-soft) border-hairline border-(--border-muted) text-zinc-500 dark:text-zinc-400 text-sm shadow-lg pointer-events-auto">
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-(--bg-soft) border border-(--border-muted) text-zinc-500 dark:text-zinc-400 text-sm shadow-lg pointer-events-auto">
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
@@ -139,13 +139,13 @@ export const RequestConfirmationModal = ({
/>
<div
className={`relative w-full max-w-xl rounded-xl border-hairline border-(--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-hairline border-(--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
</h3>
@@ -163,9 +163,9 @@ export const RequestConfirmationModal = ({
</header>
<div className="space-y-4 px-6 py-5">
<div className="rounded-xl border-hairline 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="w-16 h-24 shrink-0 rounded-lg overflow-hidden border-hairline border-(--border-muted) bg-(--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}
@@ -212,7 +212,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-hairline 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"
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 +223,12 @@ export const RequestConfirmationModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--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-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--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>
+9 -9
View File
@@ -414,7 +414,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
Boolean(autocompleteEndpoint)
&& isAutocompleteOpen
&& textInputValue.trim().length >= autocompleteMinQueryLength;
const wrapperClasses = ['relative flex items-center rounded-full border-hairline', className].filter(Boolean).join(' ').trim();
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
const controlsClasses = [
'flex items-center gap-1 pr-2 shrink-0',
controlsClassName,
@@ -640,7 +640,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{isSelectorOpen && (
<div
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border-hairline shadow-2xl animate-fade-in-down"
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl animate-fade-in-down"
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
@@ -650,7 +650,7 @@ 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-hairline pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<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>
@@ -658,7 +658,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
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'
}`}
style={contentType !== 'ebook'
@@ -671,7 +671,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
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'
}`}
style={contentType !== 'audiobook'
@@ -699,7 +699,7 @@ 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-hairline px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${
className={`min-w-0 rounded-xl border px-3 py-2.5 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
@@ -715,7 +715,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</div>
{onAdvancedToggle && (
<div className="border-t-hairline pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="border-t pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
<button
type="button"
onClick={() => {
@@ -826,7 +826,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{selectDropdownOpen && (
<div
ref={selectPanelRef}
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={effectiveInputAriaLabel}
@@ -869,7 +869,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{autocompleteDropdownOpen && (
<div
ref={autocompletePanelRef}
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={`${effectiveInputAriaLabel} suggestions`}
@@ -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-hairline border-(--border-muted) hover:bg-(--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-hairline border-(--border-muted) hover:bg-(--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-hairline 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"
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">
@@ -735,7 +735,7 @@ export const ActivitySidebar = ({
</div>
{activeTab !== 'history' && (
<div className="mt-2 border-b-hairline border-(--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
@@ -961,7 +961,7 @@ export const ActivitySidebar = ({
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
<div
className="p-3 border-t-hairline flex items-center justify-center"
className="p-3 border-t flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
@@ -979,7 +979,7 @@ export const ActivitySidebar = ({
{activeTab === 'history' && historyItems.length > 0 && (
<div
className="p-3 border-t-hairline flex items-center justify-center"
className="p-3 border-t flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
@@ -67,7 +67,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border-hairline border-emerald-700"
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -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-sm border-hairline 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}
@@ -68,7 +68,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border-hairline border-emerald-700"
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
@@ -177,7 +177,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded-sm border-hairline text-xs 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}
@@ -34,7 +34,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border-hairline border-white/40 dark:border-gray-700/70">
<div className="relative w-7 h-10 sm:w-10 sm:h-14 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-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" />
)}
@@ -129,7 +129,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-sm border-hairline border-emerald-700 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)',
@@ -252,13 +252,13 @@ export const SelfSettingsModal = ({
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border-hairline border-(--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-hairline border-(--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-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--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-hairline border-(--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-hairline border-(--border-muted) 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 && (
@@ -380,7 +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-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -440,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-hairline border-(--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)' }}
@@ -219,7 +219,7 @@ export const SettingsSidebar = ({
<span className="flex-1">{item.tab.displayName}</span>
</button>
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
)}
</div>
);
@@ -251,7 +251,7 @@ export const SettingsSidebar = ({
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b-hairline border-(--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-hairline border-(--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-hairline border-(--border-muted) py-2 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 (
@@ -318,7 +318,7 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="ml-[22px] border-l-hairline border-(--border-muted) flex flex-col gap-0.5 pl-3">
<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}
@@ -2,9 +2,9 @@ import { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border-hairline border-(--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-hairline border-(--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-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--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:pt-5 not-first:mt-1 not-first:border-t-hairline not-first:border-(--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-hairline border-(--border-muted) bg-(--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>
);
@@ -277,7 +277,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
onClick={() => toggleOption(opt.value)}
disabled={isDisabled}
className={`px-3 py-1.5 rounded-full text-sm font-medium
transition-colors border-hairline
transition-colors border
disabled:opacity-60 disabled:cursor-not-allowed
${
isSelected
@@ -20,7 +20,7 @@ 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-hairline border-(--border-muted) bg-(--bg-soft) text-sm
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,7 +233,7 @@ 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-hairline border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
border border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
`}
>
{/* Reorder Controls - hidden for pinned items */}
@@ -21,7 +21,7 @@ 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-hairline border-(--border-muted) bg-(--bg-soft) text-sm
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"
@@ -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-hairline border-(--border-muted) bg-(--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>
);
@@ -175,7 +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-(--bg-soft) border-hairline border-(--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'}
@@ -236,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-hairline border-(--border-muted) shadow-sm bg-(--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>
) : (
@@ -300,7 +300,7 @@ 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-hairline border-(--border-muted) bg-(--bg-soft) text-sm
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"
@@ -331,7 +331,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
</button>
</div>
<div className="col-span-full border-t-hairline border-(--border-muted) opacity-60" />
<div className="col-span-full border-t border-(--border-muted) opacity-60" />
</div>
))}
</div>
@@ -341,7 +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-(--bg-soft) border-hairline border-(--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,7 +82,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
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'}`}
@@ -96,7 +96,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
<span
key={`${tag}-${idx}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
border-hairline border-(--border-muted) bg-(--bg)
border border-(--border-muted) bg-(--bg)
max-w-full"
title={tag}
>
@@ -19,7 +19,7 @@ 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-hairline border-(--border-muted) bg-(--bg-soft) text-sm
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"
@@ -78,7 +78,7 @@ interface FieldWrapperProps {
const DisabledBadge = ({ reason }: { reason?: string }) => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-zinc-500/20 text-zinc-400 border-hairline border-zinc-500/30"
bg-zinc-500/20 text-zinc-400 border border-zinc-500/30"
title={reason || 'This setting is not available'}
>
<svg
@@ -103,7 +103,7 @@ const DisabledBadge = ({ reason }: { reason?: string }) => (
const RestartRequiredBadge = () => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-amber-500/20 text-amber-600 dark:text-amber-400 border-hairline border-amber-500/30"
bg-amber-500/20 text-amber-600 dark:text-amber-400 border border-amber-500/30"
title="Changing this setting requires a container restart to take effect"
>
<svg
@@ -162,7 +162,7 @@ const UserOverriddenBadge = ({
<Tooltip content={content} position="top">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-sky-500/15 text-sky-500 dark:text-sky-400 border-hairline border-sky-500/30"
bg-sky-500/15 text-sky-500 dark:text-sky-400 border border-sky-500/30"
>
User overridden{count > 1 ? ` (${count})` : ''}
</span>
@@ -5,7 +5,7 @@ interface SettingsSaveBarProps {
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="shrink-0 px-6 py-4 border-t-hairline border-(--border-muted) bg-(--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,16 +142,16 @@ export const RequestPolicyGrid = ({
type="button"
onClick={onClearOverrides}
disabled={clearOverridesDisabled}
className="px-3 py-1.5 rounded-lg text-xs font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--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-hairline border-(--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-(--bg-soft) text-xs font-medium opacity-60 border-b-hairline border-(--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>
@@ -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-hairline border-(--border-muted) bg-(--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-hairline border-(--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-hairline border-(--border-muted) bg-(--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-hairline border-(--border-muted) shadow-sm bg-(--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>
@@ -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-(--bg-soft) border-hairline border-(--border-muted) shadow-sm 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-hairline border-(--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,7 +290,7 @@ export const UserEditActions = ({
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--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>
@@ -309,7 +309,7 @@ export const UserEditActions = ({
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted)
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
@@ -319,7 +319,7 @@ export const UserEditActions = ({
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border-hairline border-red-500/40 text-red-600 hover:bg-red-500/10"
border border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
@@ -400,7 +400,7 @@ export const UserCreateCard = ({
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--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>
@@ -572,7 +572,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t-hairline border-(--border-muted)" />
<div className="border-t border-(--border-muted)" />
</>
)}
@@ -596,7 +596,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t-hairline border-(--border-muted)" />
<div className="border-t border-(--border-muted)" />
{preferencesContent}
</>
)}
@@ -96,7 +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-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--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>
@@ -117,7 +117,7 @@ export const UserListView = ({
return (
<div
key={user.id}
className={`rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--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"
@@ -143,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-hairline border-(--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`}
>
@@ -1,10 +1,11 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
HeadingFieldConfig,
MultiSelectFieldConfig,
SelectFieldConfig,
TextFieldConfig,
} from '../../../types/settings';
import { HeadingField, SelectField, TextField } from '../fields';
import { HeadingField, MultiSelectField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
@@ -76,6 +77,19 @@ const fallbackEmailRecipientField: TextFieldConfig = {
placeholder: 'reader@example.com',
};
const fallbackBrowserDownloadField: MultiSelectFieldConfig = {
type: 'MultiSelectField',
key: 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
label: 'Download to Browser',
description: 'Automatically download completed files to this browser for the selected content types.',
value: [],
variant: 'dropdown',
options: [
{ value: 'book', label: 'Books' },
{ value: 'audiobook', label: 'Audiobooks' },
],
};
type DeliverySettingKey = keyof PerUserSettings;
function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
@@ -138,11 +152,26 @@ export const UserOverridesSection = ({
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientFieldSource = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const browserDownloadField = getFieldByKey<MultiSelectFieldConfig>(
fields,
'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
fallbackBrowserDownloadField,
);
const emailRecipientField: TextFieldConfig = {
...emailRecipientFieldSource,
label: 'Email Recipient',
description: 'Email address used for this user in Email output mode.',
};
const browserDownloadGlobalValue = Array.isArray(globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
? globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
.map((entry) => String(entry).trim())
.filter((entry) => entry.length > 0)
: [];
const browserDownloadUserValue = Array.isArray(userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
? userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
.map((entry) => String(entry).trim())
.filter((entry) => entry.length > 0)
: [];
const isOverridden = (key: DeliverySettingKey): boolean => {
if (
@@ -158,6 +187,12 @@ export const UserOverridesSection = ({
return userValue !== globalValue;
};
const isBrowserDownloadOverridden = (
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES')
&& userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES !== null
&& JSON.stringify(browserDownloadUserValue) !== JSON.stringify(browserDownloadGlobalValue)
);
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
const next = { ...prev };
@@ -181,6 +216,9 @@ export const UserOverridesSection = ({
const outputModeValue = readValue('BOOKS_OUTPUT_MODE', 'folder');
const effectiveOutputMode = normalizeMode(outputModeValue);
const browserDownloadContentTypes = isBrowserDownloadOverridden
? browserDownloadUserValue
: browserDownloadGlobalValue;
const destinationValue = readValue('DESTINATION');
const destinationAudiobookValue = readValue('DESTINATION_AUDIOBOOK');
const libraryValue = readValue('BOOKLORE_LIBRARY_ID');
@@ -194,6 +232,7 @@ export const UserOverridesSection = ({
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) => isOverridden(key));
const canOverrideOutputMode = isUserOverridable('BOOKS_OUTPUT_MODE');
const canOverrideBrowserDownload = isUserOverridable('DOWNLOAD_TO_BROWSER_CONTENT_TYPES');
const canOverrideDestination = isUserOverridable('DESTINATION');
const canOverrideAudiobookDestination = isUserOverridable('DESTINATION_AUDIOBOOK');
const canOverrideBookloreLibrary = isUserOverridable('BOOKLORE_LIBRARY_ID');
@@ -207,6 +246,31 @@ export const UserOverridesSection = ({
return (
<div className="space-y-4">
<HeadingField field={deliveryHeading} />
{canOverrideBrowserDownload && (
<FieldWrapper
field={browserDownloadField}
resetAction={
isBrowserDownloadOverridden
? {
disabled: Boolean(browserDownloadField.fromEnv),
onClick: () => resetKeys(['DOWNLOAD_TO_BROWSER_CONTENT_TYPES']),
}
: undefined
}
>
<MultiSelectField
field={browserDownloadField}
value={browserDownloadContentTypes}
onChange={(value) => setUserSettings((prev) => ({
...prev,
DOWNLOAD_TO_BROWSER_CONTENT_TYPES: value,
}))}
disabled={Boolean(browserDownloadField.fromEnv)}
/>
</FieldWrapper>
)}
<HeadingField field={booksHeading} />
{canOverrideOutputMode && (
@@ -199,7 +199,7 @@ export const UserOverridesSections = ({
<div className="space-y-5">
{sectionNodes.map(({ id, node }, index) => (
<Fragment key={id}>
{index > 0 && <div className="border-t-hairline border-(--border-muted)" />}
{index > 0 && <div className="border-t border-(--border-muted)" />}
{node}
</Fragment>
))}
@@ -38,7 +38,7 @@ export const UserOverridesView = ({
<div>
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
>
<svg
className="w-4 h-4"
@@ -16,7 +16,8 @@ type SearchSettingKey =
| 'SEARCH_MODE'
| 'METADATA_PROVIDER'
| 'METADATA_PROVIDER_AUDIOBOOK'
| 'DEFAULT_RELEASE_SOURCE';
| 'DEFAULT_RELEASE_SOURCE'
| 'DEFAULT_RELEASE_SOURCE_AUDIOBOOK';
const fallbackSearchModeField: SelectFieldConfig = {
type: 'SelectField',
@@ -51,12 +52,22 @@ const fallbackAudiobookMetadataProviderField: SelectFieldConfig = {
const fallbackDefaultReleaseSourceField: SelectFieldConfig = {
type: 'SelectField',
key: 'DEFAULT_RELEASE_SOURCE',
label: 'Default Release Source',
description: 'The release source tab to open by default in the release modal.',
label: 'Default Book Release Source',
description: 'The release source tab to open by default in the release modal for books.',
value: 'direct_download',
options: [],
};
const fallbackDefaultAudiobookReleaseSourceField: SelectFieldConfig = {
type: '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.',
value: '',
options: [{ value: '', label: 'Use book release source' }],
};
const searchHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'search_preferences_heading',
@@ -110,6 +121,11 @@ export const UserSearchPreferencesSection = ({
'DEFAULT_RELEASE_SOURCE',
fallbackDefaultReleaseSourceField
);
const defaultAudiobookReleaseSourceField = getFieldByKey<SelectFieldConfig>(
fields,
'DEFAULT_RELEASE_SOURCE_AUDIOBOOK',
fallbackDefaultAudiobookReleaseSourceField
);
const isOverridden = (key: SearchSettingKey): boolean => {
if (
@@ -148,6 +164,7 @@ export const UserSearchPreferencesSection = ({
const metadataProviderValue = readValue('METADATA_PROVIDER');
const metadataProviderAudiobookValue = readValue('METADATA_PROVIDER_AUDIOBOOK');
const defaultReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE', 'direct_download');
const defaultAudiobookReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
const canOverrideSearchMode = isUserOverridable('SEARCH_MODE') && preferenceKeySet.has('SEARCH_MODE');
const canOverrideMetadataProvider = isUserOverridable('METADATA_PROVIDER')
@@ -156,12 +173,15 @@ export const UserSearchPreferencesSection = ({
&& preferenceKeySet.has('METADATA_PROVIDER_AUDIOBOOK');
const canOverrideDefaultReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE')
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE');
const canOverrideDefaultAudiobookReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE_AUDIOBOOK')
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
if (
!canOverrideSearchMode
&& !canOverrideMetadataProvider
&& !canOverrideAudiobookMetadataProvider
&& !canOverrideDefaultReleaseSource
&& !canOverrideDefaultAudiobookReleaseSource
) {
return null;
}
@@ -253,6 +273,30 @@ export const UserSearchPreferencesSection = ({
/>
</FieldWrapper>
)}
{effectiveSearchMode === 'universal' && canOverrideDefaultAudiobookReleaseSource && (
<FieldWrapper
field={defaultAudiobookReleaseSourceField}
resetAction={
isOverridden('DEFAULT_RELEASE_SOURCE_AUDIOBOOK')
? {
disabled: Boolean(defaultAudiobookReleaseSourceField.fromEnv),
onClick: () => resetKeys(['DEFAULT_RELEASE_SOURCE_AUDIOBOOK']),
}
: undefined
}
>
<SelectField
field={defaultAudiobookReleaseSourceField}
value={defaultAudiobookReleaseSourceValue}
onChange={(value) => setUserSettings((prev) => ({
...prev,
DEFAULT_RELEASE_SOURCE_AUDIOBOOK: value,
}))}
disabled={Boolean(defaultAudiobookReleaseSourceField.fromEnv)}
/>
</FieldWrapper>
)}
</div>
);
};
@@ -8,10 +8,12 @@ export interface PerUserSettings {
BOOKLORE_LIBRARY_ID?: string;
BOOKLORE_PATH_ID?: string;
EMAIL_RECIPIENT?: string;
DOWNLOAD_TO_BROWSER_CONTENT_TYPES?: string[];
SEARCH_MODE?: string;
METADATA_PROVIDER?: string;
METADATA_PROVIDER_AUDIOBOOK?: string;
DEFAULT_RELEASE_SOURCE?: string;
DEFAULT_RELEASE_SOURCE_AUDIOBOOK?: string;
USER_NOTIFICATION_ROUTES?: Array<Record<string, unknown>>;
REQUESTS_ENABLED?: boolean;
REQUEST_POLICY_DEFAULT_EBOOK?: string;
@@ -11,7 +11,7 @@ interface SearchFieldRendererProps {
}
const baseInputClass =
'w-full px-3 py-2 rounded-md border-hairline border-(--border-muted) ' +
'w-full px-3 py-2 rounded-md border border-(--border-muted) ' +
'bg-(--bg-soft) text-sm ' +
'focus:outline-hidden focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
'transition-colors';
-1
View File
@@ -23,6 +23,5 @@ export const CONTENT_OPTIONS = [
{ value: 'standards_document', label: 'Standards document' },
{ value: 'other', label: 'Other' },
{ value: 'musical_score', label: 'Musical score' },
{ value: 'audiobook', label: 'Audiobook' },
];
+1 -1
View File
@@ -22,7 +22,7 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
>
<div className="w-full max-w-md">
<div
className="rounded-lg shadow-2xl p-6 border-hairline"
className="rounded-lg shadow-2xl p-6 border"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
+2 -21
View File
@@ -28,10 +28,6 @@
}
}
@theme inline {
--border-width-hairline: var(--theme-border-width-hairline);
}
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -72,9 +68,6 @@
:root {
color-scheme: light;
/* Hairline border: 0.5px on retina, 1px fallback */
--theme-border-width-hairline: 1px;
/* Light theme variables */
--primary-color: oklch(44.3% 0.11 240.79);
--primary-dark: oklch(39.1% 0.09 240.876);
@@ -117,14 +110,6 @@
}
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 192dpi),
(min-resolution: 2dppx) {
:root {
--theme-border-width-hairline: 0.5px;
}
}
/* Global scrollbar styling - thin, subtle, theme-aware */
* {
/* Firefox */
@@ -165,8 +150,6 @@
/* Typography */
html, body {
min-height: 100vh;
/* Support for iOS notch/dynamic island - extend to full viewport */
min-height: 100dvh;
margin: 0;
padding: 0;
}
@@ -197,14 +180,12 @@
flex-direction: column;
flex: 1;
min-height: 100vh;
min-height: 100dvh;
transition: background-color 0.2s ease;
background-color: var(--background-color);
}
main {
flex: 1;
min-height: 100%;
background: var(--background-color);
display: flex;
flex-direction: column;
@@ -443,8 +424,8 @@
.modal-overlay {
padding: 0;
align-items: stretch;
/* Extend background into iOS safe areas */
background: var(--bg-soft);
/* Match page background so iOS PWA safe areas stay consistent */
background: var(--background-color);
}
.details-container {
+4 -1
View File
@@ -31,6 +31,7 @@ export interface Book {
progress?: number;
status_message?: string; // Detailed status message (e.g., "Trying Libgen (2/5)")
added_time?: number; // Timestamp when added to queue
content_type?: string; // "ebook", "audiobook", or related book subtype
source?: string; // Release source handler (e.g., "direct_download", "prowlarr")
source_display_name?: string; // Human-readable source name (e.g., "Direct Download")
// Metadata provider fields (used in universal search mode)
@@ -259,9 +260,11 @@ export interface AppConfig {
metadata_sort_options: SortOption[];
metadata_search_fields: MetadataSearchField[];
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
show_release_source_links: boolean;
books_output_mode: BooksOutputMode;
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
download_to_browser: boolean; // Auto-download completed files to browser
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
settings_enabled: boolean; // Whether config directory is mounted and writable
onboarding_complete: boolean; // Whether the user has completed initial setup
default_sort: string; // Default sort for direct mode
+18
View File
@@ -0,0 +1,18 @@
"""Tests for search mode settings definitions."""
from shelfmark.config.settings import search_mode_settings
def test_search_mode_settings_include_release_source_links_toggle():
fields = {
field.key: field
for field in search_mode_settings()
if hasattr(field, "key")
}
field = fields["SHOW_RELEASE_SOURCE_LINKS"]
assert field.label == "Show Release Source Links"
assert field.default is True
assert field.user_overridable is False
+70 -2
View File
@@ -369,8 +369,24 @@ def test_on_save_users_rejects_invalid_default_release_source_override(monkeypat
monkeypatch.setattr(
"shelfmark.release_sources.list_available_sources",
lambda: [
{"name": "direct_download", "display_name": "Direct Download", "enabled": True},
{"name": "prowlarr", "display_name": "Prowlarr", "enabled": True},
{
"name": "direct_download",
"display_name": "Direct Download",
"enabled": True,
"supported_content_types": ["ebook"],
},
{
"name": "prowlarr",
"display_name": "Prowlarr",
"enabled": True,
"supported_content_types": ["ebook", "audiobook"],
},
{
"name": "audiobookbay",
"display_name": "AudiobookBay",
"enabled": True,
"supported_content_types": ["audiobook"],
},
],
)
@@ -378,3 +394,55 @@ def test_on_save_users_rejects_invalid_default_release_source_override(monkeypat
assert result["error"] is True
assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"]
def test_on_save_users_rejects_audiobook_only_source_for_book_default(monkeypatch):
monkeypatch.setattr(
"shelfmark.release_sources.list_available_sources",
lambda: [
{
"name": "direct_download",
"display_name": "Direct Download",
"enabled": True,
"supported_content_types": ["ebook"],
},
{
"name": "audiobookbay",
"display_name": "AudiobookBay",
"enabled": True,
"supported_content_types": ["audiobook"],
},
],
)
result = users_settings_module._on_save_users({"DEFAULT_RELEASE_SOURCE": "audiobookbay"})
assert result["error"] is True
assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"]
def test_on_save_users_rejects_book_only_source_for_audiobook_default(monkeypatch):
monkeypatch.setattr(
"shelfmark.release_sources.list_available_sources",
lambda: [
{
"name": "direct_download",
"display_name": "Direct Download",
"enabled": True,
"supported_content_types": ["ebook"],
},
{
"name": "audiobookbay",
"display_name": "AudiobookBay",
"enabled": True,
"supported_content_types": ["audiobook"],
},
],
)
result = users_settings_module._on_save_users(
{"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "direct_download"}
)
assert result["error"] is True
assert "DEFAULT_RELEASE_SOURCE_AUDIOBOOK must be a valid release source name or empty" in result["message"]
+6
View File
@@ -1175,6 +1175,7 @@ class TestAdminSearchPreferences:
"METADATA_PROVIDER": "openlibrary",
"METADATA_PROVIDER_AUDIOBOOK": "",
"DEFAULT_RELEASE_SOURCE": "direct_download",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "",
}
(plugins_dir / "search_mode.json").write_text(json.dumps(search_mode_config))
@@ -1189,6 +1190,7 @@ class TestAdminSearchPreferences:
"SEARCH_MODE": "universal",
"METADATA_PROVIDER": "openlibrary",
"DEFAULT_RELEASE_SOURCE": "prowlarr",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay",
},
)
@@ -1202,6 +1204,7 @@ class TestAdminSearchPreferences:
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
]
field_keys = [field["key"] for field in data["fields"]]
@@ -1210,6 +1213,7 @@ class TestAdminSearchPreferences:
assert data["userOverrides"]["SEARCH_MODE"] == "universal"
assert data["userOverrides"]["METADATA_PROVIDER"] == "openlibrary"
assert data["userOverrides"]["DEFAULT_RELEASE_SOURCE"] == "prowlarr"
assert data["userOverrides"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"] == "audiobookbay"
assert data["effective"]["SEARCH_MODE"]["source"] == "user_override"
assert data["effective"]["SEARCH_MODE"]["value"] == "universal"
@@ -1217,6 +1221,8 @@ class TestAdminSearchPreferences:
assert data["effective"]["METADATA_PROVIDER_AUDIOBOOK"]["source"] in {"global_config", "default"}
assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["source"] == "user_override"
assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["value"] == "prowlarr"
assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["source"] == "user_override"
assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["value"] == "audiobookbay"
def test_returns_404_for_unknown_user(self, admin_client):
resp = admin_client.get("/api/admin/users/9999/search-preferences")
+56
View File
@@ -0,0 +1,56 @@
"""API tests for the frontend config endpoint."""
from __future__ import annotations
import importlib
import uuid
from unittest.mock import patch
import pytest
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _set_session(client, *, user_id: str, db_user_id: int, is_admin: bool) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["db_user_id"] = db_user_id
sess["is_admin"] = is_admin
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
def test_config_includes_release_source_links_toggle(main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
def fake_get(key, default=None, user_id=None): # noqa: ANN001
if key == "SHOW_RELEASE_SOURCE_LINKS":
return False
return default
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.app_config, "get", side_effect=fake_get):
with patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=[]):
with patch("shelfmark.metadata_providers.get_provider_search_fields", return_value=[]):
with patch("shelfmark.metadata_providers.get_provider_default_sort", return_value="relevance"):
resp = client.get("/api/config")
assert resp.status_code == 200
assert resp.json["show_release_source_links"] is False
+27
View File
@@ -224,6 +224,33 @@ class TestAtomicCopy:
assert result.exists()
assert result.read_text() == "content"
def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch):
"""Treat ESTALE on the final destination as a successful NFS publish."""
import errno
from shelfmark.download import fs
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
original_verify = fs._verify_transfer_size
def _verify(path, expected_size, action):
if path == dest:
raise OSError(getattr(errno, "ESTALE", 116), "Stale file handle", str(path))
return original_verify(path, expected_size, action)
monkeypatch.setattr(fs, "_verify_transfer_size", _verify)
monkeypatch.setattr(fs.time, "sleep", lambda *_args, **_kwargs: None)
result = fs.atomic_copy(source, dest)
assert result == dest
assert dest.exists()
assert dest.read_text() == "content"
assert source.exists()
def test_publish_does_not_depend_on_hardlinks(self, tmp_path, monkeypatch):
"""Temp-file publish succeeds even when hardlinks are unavailable."""
+128
View File
@@ -280,6 +280,42 @@ class TestQBittorrentClientGetStatus:
assert status.complete is True
assert status.file_path == "/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub"
def test_get_status_paused_up_complete(self, monkeypatch):
"""qBittorrent-compatible clients may report completed items as pausedUP."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
progress=1.0,
state="pausedUP",
content_path="/downloads/completed.epub",
)
mock_client_instance = MagicMock()
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("abc123")
assert status.complete is True
assert status.state_value == "complete"
assert status.file_path == "/downloads/completed.epub"
def test_get_status_complete_derives_when_content_path_equals_save_path(self, monkeypatch):
"""Keep get_status() and get_download_path() consistent."""
config_values = {
@@ -586,6 +622,98 @@ class TestQBittorrentClientAddDownload:
mock_client_instance.torrents_create_category.assert_called_once_with(name="books")
def test_add_download_accepts_empty_success_response(self, monkeypatch):
"""qBittorrent-compatible shims may return HTTP 200 with an empty body."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = ""
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
result = client.add_download(magnet, "Test")
assert result == valid_hash
assert mock_client_instance._session.get.call_count >= 1
def test_add_download_explicit_failure_response_raises(self, monkeypatch):
"""Explicit failure responses should still fail fast."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Fails."
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
with pytest.raises(Exception, match="Failed to add torrent: Fails\\."):
client.add_download(magnet, "Test")
def test_add_download_omits_empty_category(self, monkeypatch):
"""Avoid sending empty category values to qBittorrent-compatible clients."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
client.add_download(magnet, "Test")
mock_client_instance.torrents_create_category.assert_not_called()
call_kwargs = mock_client_instance.torrents_add.call_args.kwargs
assert "category" not in call_kwargs
def test_add_download_uses_configured_download_dir(self, monkeypatch):
"""Test that add_download passes configured download directory."""
config_values = {