diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml index c31a4bc..f63411d 100644 --- a/docker-compose.test-clients.yml +++ b/docker-compose.test-clients.yml @@ -11,7 +11,7 @@ # - Prowlarr: http://localhost:9696 (no auth by default) # - qBittorrent: http://localhost:8080 (check container logs for temp password) # - Transmission: http://localhost:9091 (admin / admin) -# - Deluge: http://localhost:8112 (admin / deluge) +# - Deluge: http://localhost:8112 (password: deluge) # - NZBGet: http://localhost:6789 (nzbget / tegbzn6789) # - SABnzbd: http://localhost:8085 (complete setup wizard for API key) # - rTorrent: http://localhost:8000 (admin / admin - if auth enabled) @@ -33,7 +33,7 @@ services: # Use Docker service names for URLs: # - qBittorrent: http://qbittorrent:8080 # - Transmission: http://transmission:9091 - # - Deluge host: deluge (port 58846) + # - Deluge Web UI: http://deluge:8112 # - NZBGet: http://nzbget:6789 # - SABnzbd: http://sabnzbd:8080 # - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..24105cf --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,3 @@ +# Configuration + +TODO diff --git a/docs/dev/index.md b/docs/dev/index.md new file mode 100644 index 0000000..c066aca --- /dev/null +++ b/docs/dev/index.md @@ -0,0 +1,3 @@ +# Developer Documentation + +TODO diff --git a/docs/plugin-settings.md b/docs/dev/plugin-settings.md similarity index 100% rename from docs/plugin-settings.md rename to docs/dev/plugin-settings.md diff --git a/docs/release-sources-plugin-guide.md b/docs/dev/release-sources-plugin-guide.md similarity index 100% rename from docs/release-sources-plugin-guide.md rename to docs/dev/release-sources-plugin-guide.md diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 0000000..e90048d --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,1535 @@ +# Environment Variables + +This document lists all configuration options that can be set via environment variables. + +> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate. + +## Table of Contents + +- [Bootstrap Configuration](#bootstrap-configuration) +- [General](#general) +- [Search Mode](#search-mode) +- [Downloads](#downloads) +- [Network](#network) +- [Advanced](#advanced) +- [IRC](#irc) +- [Metadata Providers](#metadata-providers) + - [Hardcover](#metadata-providers-hardcover) + - [Open Library](#metadata-providers-open-library) + - [Google Books](#metadata-providers-google-books) +- [Direct Download](#direct-download) + - [Download Sources](#direct-download-download-sources) + - [Cloudflare Bypass](#direct-download-cloudflare-bypass) + - [Mirrors](#direct-download-mirrors) +- [Prowlarr](#prowlarr) + - [Configuration](#prowlarr-configuration) + - [Download Clients](#prowlarr-download-clients) + +--- + +## Bootstrap Configuration + +These environment variables are used at startup before the settings system loads. They typically configure paths and server settings. + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `CONFIG_DIR` | Directory for storing configuration files and plugin settings. | string (path) | `/config` | +| `LOG_ROOT` | Root directory for log files. | string (path) | `/var/log/` | +| `TMP_DIR` | Staging directory for downloads before moving to destination. | string (path) | `/tmp/shelfmark` | +| `ENABLE_LOGGING` | Enable file logging to LOG_ROOT/shelfmark/shelfmark.log. | boolean | `true` | +| `FLASK_HOST` | Host address for the Flask web server. | string | `0.0.0.0` | +| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` | +| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` | +| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` | +| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` | + +
+Detailed descriptions + +#### `CONFIG_DIR` + +Directory for storing configuration files and plugin settings. + +- **Type:** string (path) +- **Default:** `/config` + +#### `LOG_ROOT` + +Root directory for log files. + +- **Type:** string (path) +- **Default:** `/var/log/` + +#### `TMP_DIR` + +Staging directory for downloads before moving to destination. + +- **Type:** string (path) +- **Default:** `/tmp/shelfmark` + +#### `ENABLE_LOGGING` + +Enable file logging to LOG_ROOT/shelfmark/shelfmark.log. + +- **Type:** boolean +- **Default:** `true` + +#### `FLASK_HOST` + +Host address for the Flask web server. + +- **Type:** string +- **Default:** `0.0.0.0` + +#### `FLASK_PORT` + +Port number for the Flask web server. + +- **Type:** number +- **Default:** `8084` + +#### `SESSION_COOKIE_SECURE` + +Enable secure cookies (requires HTTPS). + +- **Type:** boolean +- **Default:** `false` + +#### `CWA_DB_PATH` + +Path to the Calibre-Web database for authentication integration. + +- **Type:** string (path) +- **Default:** `/auth/app.db` + +#### `DOCKERMODE` + +Indicates the application is running inside a Docker container. + +- **Type:** boolean +- **Default:** `false` + +
+ +## General + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc). | string | _none_ | +| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ | +| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` | +| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3` | +| `BOOK_LANGUAGE` | Default language filter for searches. | string (comma-separated) | `en` | + +
+Detailed descriptions + +#### `CALIBRE_WEB_URL` + +**Library URL** + +Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc). + +- **Type:** string +- **Default:** _none_ + +#### `AUDIOBOOK_LIBRARY_URL` + +**Audiobook Library URL** + +Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. + +- **Type:** string +- **Default:** _none_ + +#### `SUPPORTED_FORMATS` + +**Supported Book Formats** + +Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. + +- **Type:** string (comma-separated) +- **Default:** `epub,mobi,azw3,fb2,djvu,cbz,cbr` + +#### `SUPPORTED_AUDIOBOOK_FORMATS` + +**Supported Audiobook Formats** + +Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. + +- **Type:** string (comma-separated) +- **Default:** `m4b,mp3` + +#### `BOOK_LANGUAGE` + +**Default Book Languages** + +Default language filter for searches. + +- **Type:** string (comma-separated) +- **Default:** `en` + +
+ +## Search Mode + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `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` | +| `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` | + +
+Detailed descriptions + +#### `SEARCH_MODE` + +**Search Mode** + +How you want to search for and download books. + +- **Type:** string (choice) +- **Default:** `direct` +- **Options:** Direct, Universal + +#### `AA_DEFAULT_SORT` + +**Default Sort Order** + +Default sort order for search results. + +- **Type:** string (choice) +- **Default:** `relevance` +- **Options:** Most relevant, Newest (publication year), Oldest (publication year), Largest (filesize), Smallest (filesize), Newest (open sourced), Oldest (open sourced) + +#### `METADATA_PROVIDER` + +**Book Metadata Provider** + +Choose which metadata provider to use for book searches. + +- **Type:** string (choice) +- **Default:** `openlibrary` +- **Options:** No providers enabled + +#### `METADATA_PROVIDER_AUDIOBOOK` + +**Audiobook Metadata Provider** + +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 + +#### `DEFAULT_RELEASE_SOURCE` + +**Default Release Source** + +The release source tab to open by default in the release modal. + +- **Type:** string (choice) +- **Default:** `direct_download` +- **Options:** Direct Download, Prowlarr + +
+ +## Downloads + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` | +| `INGEST_DIR` | Directory where downloaded files are saved. | string | `/books` | +| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` | +| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` | +| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle} | string | `{Author}/{Title} ({Year})` | +| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` | +| `BOOKLORE_HOST` | Base URL of your Booklore instance | string | _none_ | +| `BOOKLORE_USERNAME` | Booklore account username | string | _none_ | +| `BOOKLORE_PASSWORD` | Booklore account password | string (secret) | _none_ | +| `BOOKLORE_LIBRARY_ID` | Booklore library to upload into. | string (choice) | _none_ | +| `BOOKLORE_PATH_ID` | Booklore library path for uploads. | string (choice) | _none_ | +| `DESTINATION_AUDIOBOOK` | Leave empty to use Books destination. | string | _none_ | +| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` | +| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` | +| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber} | 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` | +| `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` | + +
+Detailed descriptions + +#### `BOOKS_OUTPUT_MODE` + +**Output Mode** + +Choose where completed book files are sent. + +- **Type:** string (choice) +- **Default:** `folder` +- **Options:** Folder, Booklore (API) + +#### `INGEST_DIR` + +**Destination** + +Directory where downloaded files are saved. + +- **Type:** string +- **Default:** `/books` +- **Required:** Yes + +#### `FILE_ORGANIZATION` + +**File Organization** + +Choose how downloaded book files are named and organized. + +- **Type:** string (choice) +- **Default:** `rename` +- **Options:** None, Rename, Organize + +#### `TEMPLATE_RENAME` + +**Naming Template** + +Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Rename templates are filename-only (no '/' or '\'); use Organize for folders. + +- **Type:** string +- **Default:** `{Author} - {Title} ({Year})` + +#### `TEMPLATE_ORGANIZE` + +**Path Template** + +Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle} + +- **Type:** string +- **Default:** `{Author}/{Title} ({Year})` + +#### `HARDLINK_TORRENTS` + +**Hardlink Book Torrents** + +Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. + +- **Type:** boolean +- **Default:** `false` + +#### `BOOKLORE_HOST` + +**Booklore URL** + +Base URL of your Booklore instance + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `BOOKLORE_USERNAME` + +**Username** + +Booklore account username + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `BOOKLORE_PASSWORD` + +**Password** + +Booklore account password + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + +#### `BOOKLORE_LIBRARY_ID` + +**Library** + +Booklore library to upload into. + +- **Type:** string (choice) +- **Default:** _none_ +- **Required:** Yes + +#### `BOOKLORE_PATH_ID` + +**Path** + +Booklore library path for uploads. + +- **Type:** string (choice) +- **Default:** _none_ +- **Required:** Yes + +#### `DESTINATION_AUDIOBOOK` + +**Destination** + +Leave empty to use Books destination. + +- **Type:** string +- **Default:** _none_ + +#### `FILE_ORGANIZATION_AUDIOBOOK` + +**File Organization** + +Choose how downloaded audiobook files are named and organized. + +- **Type:** string (choice) +- **Default:** `rename` +- **Options:** None, Rename, Organize + +#### `TEMPLATE_AUDIOBOOK_RENAME` + +**Naming Template** + +Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Rename templates are filename-only (no '/' or '\'); use Organize for folders. + +- **Type:** string +- **Default:** `{Author} - {Title}` + +#### `TEMPLATE_AUDIOBOOK_ORGANIZE` + +**Path Template** + +Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber} + +- **Type:** string +- **Default:** `{Author}/{Title}` + +#### `HARDLINK_TORRENTS_AUDIOBOOK` + +**Hardlink Audiobook Torrents** + +Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. + +- **Type:** boolean +- **Default:** `true` + +#### `AUTO_OPEN_DOWNLOADS_SIDEBAR` + +**Auto-Open Downloads Sidebar** + +Automatically open the downloads sidebar when a new download is queued. + +- **Type:** boolean +- **Default:** `false` + +#### `DOWNLOAD_TO_BROWSER` + +**Download to Browser** + +Automatically download completed files to your browser. + +- **Type:** boolean +- **Default:** `false` + +#### `MAX_CONCURRENT_DOWNLOADS` + +**Max Concurrent Downloads** + +Maximum number of simultaneous downloads. + +- **Type:** number +- **Default:** `3` +- **Requires restart:** Yes +- **Constraints:** min: 1, max: 10 + +#### `STATUS_TIMEOUT` + +**Status Timeout (seconds)** + +How long to keep completed/failed downloads in the queue display. + +- **Type:** number +- **Default:** `3600` +- **Constraints:** min: 60, max: 86400 + +
+ +## Network + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `CUSTOM_DNS` | DNS provider for domain resolution. 'Auto' rotates through providers on failure. | string (choice) | `auto` | +| `CUSTOM_DNS_MANUAL` | Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1). | string | _none_ | +| `USE_DOH` | Use encrypted DNS queries for improved reliability and privacy. | boolean | `true` | +| `USING_TOR` | Route all traffic through Tor for enhanced privacy. | boolean | `false` | +| `PROXY_MODE` | Choose proxy type. SOCKS5 handles all traffic through a single proxy. | string (choice) | `none` | +| `HTTP_PROXY` | HTTP proxy URL (e.g., http://proxy:8080) | string | _none_ | +| `HTTPS_PROXY` | HTTPS proxy URL (leave empty to use HTTP proxy for HTTPS) | string | _none_ | +| `SOCKS5_PROXY` | SOCKS5 proxy URL. Supports auth: socks5://user:pass@host:port | string | _none_ | +| `NO_PROXY` | Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local) | string | _none_ | + +
+Detailed descriptions + +#### `CUSTOM_DNS` + +**DNS Provider** + +DNS provider for domain resolution. 'Auto' rotates through providers on failure. + +- **Type:** string (choice) +- **Default:** `auto` +- **Options:** Auto (Recommended), System, Google, Cloudflare, Quad9, OpenDNS, Manual + +#### `CUSTOM_DNS_MANUAL` + +**Manual DNS Servers** + +Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1). + +- **Type:** string +- **Default:** _none_ + +#### `USE_DOH` + +**Use DNS over HTTPS** + +Use encrypted DNS queries for improved reliability and privacy. + +- **Type:** boolean +- **Default:** `true` + +#### `USING_TOR` + +**Tor Routing** + +Route all traffic through Tor for enhanced privacy. + +- **Type:** boolean +- **Default:** `false` + +#### `PROXY_MODE` + +**Proxy Mode** + +Choose proxy type. SOCKS5 handles all traffic through a single proxy. + +- **Type:** string (choice) +- **Default:** `none` +- **Options:** None (Direct Connection), HTTP/HTTPS Proxy, SOCKS5 Proxy + +#### `HTTP_PROXY` + +**HTTP Proxy** + +HTTP proxy URL (e.g., http://proxy:8080) + +- **Type:** string +- **Default:** _none_ + +#### `HTTPS_PROXY` + +**HTTPS Proxy** + +HTTPS proxy URL (leave empty to use HTTP proxy for HTTPS) + +- **Type:** string +- **Default:** _none_ + +#### `SOCKS5_PROXY` + +**SOCKS5 Proxy** + +SOCKS5 proxy URL. Supports auth: socks5://user:pass@host:port + +- **Type:** string +- **Default:** _none_ + +#### `NO_PROXY` + +**No Proxy** + +Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local) + +- **Type:** string +- **Default:** _none_ + +
+ +## Advanced + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ | +| `DEBUG` | Enable verbose logging to console and file. Not recommended for normal use. | boolean | `false` | +| `MAIN_LOOP_SLEEP_TIME` | How often the download queue is checked for new items. | number | `5` | +| `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` | +| `COVERS_CACHE_ENABLED` | Cache book covers on the server for faster loading. | boolean | `true` | +| `COVERS_CACHE_TTL` | How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork). | number | `0` | +| `COVERS_CACHE_MAX_SIZE_MB` | Maximum disk space for cached covers. Oldest images are removed when limit is reached. | number | `500` | +| `METADATA_CACHE_ENABLED` | When disabled, all metadata searches hit the provider API directly. | boolean | `true` | +| `METADATA_CACHE_SEARCH_TTL` | How long to cache search results. Default: 300 (5 minutes). Max: 604800 (7 days). | number | `300` | +| `METADATA_CACHE_BOOK_TTL` | How long to cache individual book details. Default: 600 (10 minutes). Max: 604800 (7 days). | number | `600` | + +
+Detailed descriptions + +#### `CUSTOM_SCRIPT` + +**Custom Script Path** + +Path to a script to run after each successful download. Must be executable. + +- **Type:** string +- **Default:** _none_ + +#### `DEBUG` + +**Debug Mode** + +Enable verbose logging to console and file. Not recommended for normal use. + +- **Type:** boolean +- **Default:** `false` +- **Requires restart:** Yes + +#### `MAIN_LOOP_SLEEP_TIME` + +**Queue Check Interval (seconds)** + +How often the download queue is checked for new items. + +- **Type:** number +- **Default:** `5` +- **Requires restart:** Yes +- **Constraints:** min: 1, max: 60 + +#### `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` + +**Progress Update Interval (seconds)** + +How often download progress is broadcast to the UI. + +- **Type:** number +- **Default:** `1` +- **Requires restart:** Yes +- **Constraints:** min: 1, max: 10 + +#### `COVERS_CACHE_ENABLED` + +**Enable Cover Cache** + +Cache book covers on the server for faster loading. + +- **Type:** boolean +- **Default:** `true` + +#### `COVERS_CACHE_TTL` + +**Cache TTL (days)** + +How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork). + +- **Type:** number +- **Default:** `0` +- **Constraints:** min: 0, max: 365 + +#### `COVERS_CACHE_MAX_SIZE_MB` + +**Max Cache Size (MB)** + +Maximum disk space for cached covers. Oldest images are removed when limit is reached. + +- **Type:** number +- **Default:** `500` +- **Constraints:** min: 50, max: 5000 + +#### `METADATA_CACHE_ENABLED` + +**Enable Metadata Caching** + +When disabled, all metadata searches hit the provider API directly. + +- **Type:** boolean +- **Default:** `true` + +#### `METADATA_CACHE_SEARCH_TTL` + +**Search Results Cache (seconds)** + +How long to cache search results. Default: 300 (5 minutes). Max: 604800 (7 days). + +- **Type:** number +- **Default:** `300` +- **Constraints:** min: 60, max: 604800 + +#### `METADATA_CACHE_BOOK_TTL` + +**Book Details Cache (seconds)** + +How long to cache individual book details. Default: 600 (10 minutes). Max: 604800 (7 days). + +- **Type:** number +- **Default:** `600` +- **Constraints:** min: 60, max: 604800 + +
+ +## IRC + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `IRC_SERVER` | IRC server hostname | string | _none_ | +| `IRC_PORT` | IRC server port (usually 6697 for TLS, 6667 for plain) | number | `6697` | +| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` | +| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ | +| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ | +| `IRC_SEARCH_BOT` | The search bot to query for results | string | _none_ | +| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` | + +
+Detailed descriptions + +#### `IRC_SERVER` + +**Server** + +IRC server hostname + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `IRC_PORT` + +**Port** + +IRC server port (usually 6697 for TLS, 6667 for plain) + +- **Type:** number +- **Default:** `6697` + +#### `IRC_USE_TLS` + +**Use TLS** + +Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. + +- **Type:** boolean +- **Default:** `true` + +#### `IRC_CHANNEL` + +**Channel** + +Channel name without the # prefix + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `IRC_NICK` + +**Nickname** + +Your IRC nickname (required). Must be unique on the IRC network. + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `IRC_SEARCH_BOT` + +**Search bot** + +The search bot to query for results + +- **Type:** string +- **Default:** _none_ + +#### `IRC_CACHE_TTL` + +**Cache Duration** + +How long to keep cached search results before they expire. + +- **Type:** string (choice) +- **Default:** `2592000` +- **Options:** 30 days, Forever (until manually cleared) + +
+ +## Metadata Providers + +### Metadata Providers: Hardcover + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `HARDCOVER_ENABLED` | Enable Hardcover as a metadata provider for book searches | boolean | `false` | +| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | string (secret) | _none_ | +| `HARDCOVER_DEFAULT_SORT` | Default sort order for Hardcover search results. | string (choice) | `relevance` | +| `HARDCOVER_EXCLUDE_COMPILATIONS` | Filter out compilations, anthologies, and omnibus editions from search results | boolean | `false` | +| `HARDCOVER_EXCLUDE_UNRELEASED` | Filter out books with a release year in the future | boolean | `false` | + +
+Detailed descriptions + +#### `HARDCOVER_ENABLED` + +**Enable Hardcover** + +Enable Hardcover as a metadata provider for book searches + +- **Type:** boolean +- **Default:** `false` + +#### `HARDCOVER_API_KEY` + +**API Key** + +Get your API key from hardcover.app/account/api + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + +#### `HARDCOVER_DEFAULT_SORT` + +**Default Sort Order** + +Default sort order for Hardcover search results. + +- **Type:** string (choice) +- **Default:** `relevance` +- **Options:** Most relevant, Most popular, Highest rated, Newest, Oldest + +#### `HARDCOVER_EXCLUDE_COMPILATIONS` + +**Exclude Compilations** + +Filter out compilations, anthologies, and omnibus editions from search results + +- **Type:** boolean +- **Default:** `false` + +#### `HARDCOVER_EXCLUDE_UNRELEASED` + +**Exclude Unreleased Books** + +Filter out books with a release year in the future + +- **Type:** boolean +- **Default:** `false` + +
+ +### Metadata Providers: Open Library + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `OPENLIBRARY_ENABLED` | Enable Open Library as a metadata provider for book searches | boolean | `false` | +| `OPENLIBRARY_DEFAULT_SORT` | Default sort order for Open Library search results. | string (choice) | `relevance` | + +
+Detailed descriptions + +#### `OPENLIBRARY_ENABLED` + +**Enable Open Library** + +Enable Open Library as a metadata provider for book searches + +- **Type:** boolean +- **Default:** `false` + +#### `OPENLIBRARY_DEFAULT_SORT` + +**Default Sort Order** + +Default sort order for Open Library search results. + +- **Type:** string (choice) +- **Default:** `relevance` +- **Options:** Most relevant, Newest, Oldest + +
+ +### Metadata Providers: Google Books + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `GOOGLEBOOKS_ENABLED` | Enable Google Books as a metadata provider for book searches | boolean | `false` | +| `GOOGLEBOOKS_API_KEY` | Get your API key from Google Cloud Console (APIs & Services > Credentials) | string (secret) | _none_ | +| `GOOGLEBOOKS_DEFAULT_SORT` | Default sort order for Google Books search results. | string (choice) | `relevance` | + +
+Detailed descriptions + +#### `GOOGLEBOOKS_ENABLED` + +**Enable Google Books** + +Enable Google Books as a metadata provider for book searches + +- **Type:** boolean +- **Default:** `false` + +#### `GOOGLEBOOKS_API_KEY` + +**API Key** + +Get your API key from Google Cloud Console (APIs & Services > Credentials) + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + +#### `GOOGLEBOOKS_DEFAULT_SORT` + +**Default Sort Order** + +Default sort order for Google Books search results. + +- **Type:** string (choice) +- **Default:** `relevance` +- **Options:** Most relevant, Newest + +
+ +## Direct Download + +### Direct Download: Download Sources + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `AA_DONATOR_KEY` | Enables fast download access on AA. Get this from your donator account page. | string (secret) | _none_ | +| `FAST_SOURCES_DISPLAY` | Always tried first, no waiting or bypass required. | JSON array | _see UI for defaults_ | +| `SOURCE_PRIORITY` | Fallback sources, may have waiting. Requires bypasser. Drag to reorder. | JSON array | _see UI for defaults_ | +| `MAX_RETRY` | Maximum retry attempts for failed downloads. | number | `10` | +| `DEFAULT_SLEEP` | Wait time between download retry attempts. | number | `5` | +| `AA_CONTENT_TYPE_ROUTING` | Override destination based on content type metadata. | boolean | `false` | +| `AA_CONTENT_TYPE_DIR_FICTION` | Fiction Books | string | _none_ | +| `AA_CONTENT_TYPE_DIR_NON_FICTION` | Non-Fiction Books | string | _none_ | +| `AA_CONTENT_TYPE_DIR_UNKNOWN` | Unknown Books | string | _none_ | +| `AA_CONTENT_TYPE_DIR_MAGAZINE` | Magazines | string | _none_ | +| `AA_CONTENT_TYPE_DIR_COMIC` | Comic Books | string | _none_ | +| `AA_CONTENT_TYPE_DIR_STANDARDS` | Standards Documents | string | _none_ | +| `AA_CONTENT_TYPE_DIR_MUSICAL_SCORE` | Musical Scores | string | _none_ | +| `AA_CONTENT_TYPE_DIR_OTHER` | Other | string | _none_ | + +
+Detailed descriptions + +#### `AA_DONATOR_KEY` + +**Account Donator Key** + +Enables fast download access on AA. Get this from your donator account page. + +- **Type:** string (secret) +- **Default:** _none_ + +#### `FAST_SOURCES_DISPLAY` + +**Fast downloads** + +Always tried first, no waiting or bypass required. + +- **Type:** JSON array +- **Default:** _see UI for defaults_ + +#### `SOURCE_PRIORITY` + +**Slow downloads** + +Fallback sources, may have waiting. Requires bypasser. Drag to reorder. + +- **Type:** JSON array +- **Default:** _see UI for defaults_ + +#### `MAX_RETRY` + +**Max Retries** + +Maximum retry attempts for failed downloads. + +- **Type:** number +- **Default:** `10` +- **Constraints:** min: 1, max: 50 + +#### `DEFAULT_SLEEP` + +**Retry Delay (seconds)** + +Wait time between download retry attempts. + +- **Type:** number +- **Default:** `5` +- **Constraints:** min: 1, max: 60 + +#### `AA_CONTENT_TYPE_ROUTING` + +**Enable Content-Type Routing** + +Override destination based on content type metadata. + +- **Type:** boolean +- **Default:** `false` + +#### `AA_CONTENT_TYPE_DIR_FICTION` + +**Fiction Books** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_NON_FICTION` + +**Non-Fiction Books** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_UNKNOWN` + +**Unknown Books** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_MAGAZINE` + +**Magazines** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_COMIC` + +**Comic Books** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_STANDARDS` + +**Standards Documents** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_MUSICAL_SCORE` + +**Musical Scores** + +- **Type:** string +- **Default:** _none_ + +#### `AA_CONTENT_TYPE_DIR_OTHER` + +**Other** + +- **Type:** string +- **Default:** _none_ + +
+ +### Direct Download: Cloudflare Bypass + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `USE_CF_BYPASS` | Attempt to bypass Cloudflare protection on download sites. | boolean | `true` | +| `USING_EXTERNAL_BYPASSER` | Use FlareSolverr or similar external service instead of built-in bypasser. Caution: May have limitations with custom DNS, Tor and proxies. You may experience slower downloads and and poorer reliability compared to the internal bypasser. | boolean | `false` | +| `EXT_BYPASSER_URL` | URL of the external bypasser service (e.g., FlareSolverr). | string | `http://flaresolverr:8191` | +| `EXT_BYPASSER_PATH` | API path for the external bypasser. | string | `/v1` | +| `EXT_BYPASSER_TIMEOUT` | Timeout for external bypasser requests in milliseconds. | number | `60000` | + +
+Detailed descriptions + +#### `USE_CF_BYPASS` + +**Enable Cloudflare Bypass** + +Attempt to bypass Cloudflare protection on download sites. + +- **Type:** boolean +- **Default:** `true` +- **Requires restart:** Yes + +#### `USING_EXTERNAL_BYPASSER` + +**Use External Bypasser** + +Use FlareSolverr or similar external service instead of built-in bypasser. Caution: May have limitations with custom DNS, Tor and proxies. You may experience slower downloads and and poorer reliability compared to the internal bypasser. + +- **Type:** boolean +- **Default:** `false` +- **Requires restart:** Yes + +#### `EXT_BYPASSER_URL` + +**External Bypasser URL** + +URL of the external bypasser service (e.g., FlareSolverr). + +- **Type:** string +- **Default:** `http://flaresolverr:8191` +- **Requires restart:** Yes + +#### `EXT_BYPASSER_PATH` + +**External Bypasser Path** + +API path for the external bypasser. + +- **Type:** string +- **Default:** `/v1` +- **Requires restart:** Yes + +#### `EXT_BYPASSER_TIMEOUT` + +**External Bypasser Timeout (ms)** + +Timeout for external bypasser requests in milliseconds. + +- **Type:** number +- **Default:** `60000` +- **Requires restart:** Yes +- **Constraints:** min: 10000, max: 300000 + +
+ +### Direct Download: Mirrors + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `AA_BASE_URL` | Select 'Auto' to probe mirrors on startup, or choose a specific mirror. | string (choice) | `auto` | +| `AA_ADDITIONAL_URLS` | Comma-separated list of custom mirror URLs. | string | _none_ | +| `LIBGEN_ADDITIONAL_URLS` | Comma-separated list of custom LibGen mirrors to add to the defaults. | string | _none_ | +| `ZLIB_PRIMARY_URL` | Z-Library mirror to use for downloads. | string (choice) | `https://z-lib.fm` | +| `ZLIB_ADDITIONAL_URLS` | Comma-separated list of custom Z-Library mirror URLs. | string | _none_ | +| `WELIB_PRIMARY_URL` | Welib mirror to use for downloads. | string (choice) | `https://welib.org` | +| `WELIB_ADDITIONAL_URLS` | Comma-separated list of custom Welib mirror URLs. | string | _none_ | + +
+Detailed descriptions + +#### `AA_BASE_URL` + +**Primary Mirror** + +Select 'Auto' to probe mirrors on startup, or choose a specific mirror. + +- **Type:** string (choice) +- **Default:** `auto` +- **Options:** Auto (Recommended), annas-archive.se, annas-archive.li, annas-archive.pm, annas-archive.in + +#### `AA_ADDITIONAL_URLS` + +**Additional Mirrors** + +Comma-separated list of custom mirror URLs. + +- **Type:** string +- **Default:** _none_ + +#### `LIBGEN_ADDITIONAL_URLS` + +**Additional Mirrors** + +Comma-separated list of custom LibGen mirrors to add to the defaults. + +- **Type:** string +- **Default:** _none_ + +#### `ZLIB_PRIMARY_URL` + +**Primary Mirror** + +Z-Library mirror to use for downloads. + +- **Type:** string (choice) +- **Default:** `https://z-lib.fm` +- **Options:** z-lib.fm, z-lib.gs, z-lib.id, z-library.sk, zlibrary-global.se + +#### `ZLIB_ADDITIONAL_URLS` + +**Additional Mirrors** + +Comma-separated list of custom Z-Library mirror URLs. + +- **Type:** string +- **Default:** _none_ + +#### `WELIB_PRIMARY_URL` + +**Primary Mirror** + +Welib mirror to use for downloads. + +- **Type:** string (choice) +- **Default:** `https://welib.org` +- **Options:** welib.org + +#### `WELIB_ADDITIONAL_URLS` + +**Additional Mirrors** + +Comma-separated list of custom Welib mirror URLs. + +- **Type:** string +- **Default:** _none_ + +
+ +## Prowlarr + +### Prowlarr: Configuration + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `PROWLARR_ENABLED` | Enable searching for books via Prowlarr indexers | boolean | `false` | +| `PROWLARR_URL` | Base URL of your Prowlarr instance | string | _none_ | +| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ | +| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ | +| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | + +
+Detailed descriptions + +#### `PROWLARR_ENABLED` + +**Enable Prowlarr source** + +Enable searching for books via Prowlarr indexers + +- **Type:** boolean +- **Default:** `false` + +#### `PROWLARR_URL` + +**Prowlarr URL** + +Base URL of your Prowlarr instance + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `PROWLARR_API_KEY` + +**API Key** + +Found in Prowlarr: Settings > General > API Key + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + +#### `PROWLARR_INDEXERS` + +**Indexers to Search** + +Select which indexers to search. 📚 = has book categories. Leave empty to search all. + +- **Type:** string (comma-separated) +- **Default:** _empty list_ + +#### `PROWLARR_AUTO_EXPAND` + +**Auto-expand search on no results** + +Automatically retry search without category filtering if no results are found + +- **Type:** boolean +- **Default:** `false` + +
+ +### Prowlarr: Download Clients + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | +| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | +| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | +| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | +| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` | +| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `TRANSMISSION_URL` | URL of your Transmission instance | string | _none_ | +| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ | +| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ | +| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` | +| `TRANSMISSION_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | +| `DELUGE_HOST` | Hostname/IP or full URL of your Deluge Web UI (deluge-web) | string | `localhost` | +| `DELUGE_PORT` | Deluge Web UI port (default: 8112) | string | `8112` | +| `DELUGE_PASSWORD` | Deluge Web UI password (default: deluge) | string (secret) | _none_ | +| `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` | +| `DELUGE_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | +| `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` | +| `NZBGET_PASSWORD` | NZBGet control password | string (secret) | _none_ | +| `NZBGET_CATEGORY` | Category to assign to book downloads in NZBGet | string | `Books` | +| `NZBGET_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `SABNZBD_URL` | URL of your SABnzbd instance | string | _none_ | +| `SABNZBD_API_KEY` | Found in SABnzbd: Config > General > API Key | string (secret) | _none_ | +| `SABNZBD_CATEGORY` | Category to assign to book downloads in SABnzbd | string | `books` | +| `SABNZBD_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `PROWLARR_USENET_ACTION` | Copy files into your ingest folder, optionally cleaning up the usenet client | string (choice) | `move` | + +
+Detailed descriptions + +#### `PROWLARR_TORRENT_CLIENT` + +**Torrent Client** + +Choose which torrent client to use + +- **Type:** string (choice) +- **Default:** _empty string_ +- **Options:** None, qBittorrent, Transmission, Deluge + +#### `QBITTORRENT_URL` + +**qBittorrent URL** + +Web UI URL of your qBittorrent instance + +- **Type:** string +- **Default:** _none_ + +#### `QBITTORRENT_USERNAME` + +**Username** + +qBittorrent Web UI username + +- **Type:** string +- **Default:** _none_ + +#### `QBITTORRENT_PASSWORD` + +**Password** + +qBittorrent Web UI password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `QBITTORRENT_CATEGORY` + +**Book Category** + +Category to assign to book downloads in qBittorrent + +- **Type:** string +- **Default:** `books` + +#### `QBITTORRENT_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `TRANSMISSION_URL` + +**Transmission URL** + +URL of your Transmission instance + +- **Type:** string +- **Default:** _none_ + +#### `TRANSMISSION_USERNAME` + +**Username** + +Transmission RPC username (if authentication enabled) + +- **Type:** string +- **Default:** _none_ + +#### `TRANSMISSION_PASSWORD` + +**Password** + +Transmission RPC password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `TRANSMISSION_CATEGORY` + +**Book Label** + +Label to assign to book downloads in Transmission + +- **Type:** string +- **Default:** `books` + +#### `TRANSMISSION_CATEGORY_AUDIOBOOK` + +**Audiobook Label** + +Label for audiobook downloads. Leave empty to use the book label. + +- **Type:** string +- **Default:** _empty string_ + +#### `DELUGE_HOST` + +**Deluge Web UI Host/URL** + +Hostname/IP or full URL of your Deluge Web UI (deluge-web) + +- **Type:** string +- **Default:** `localhost` + +#### `DELUGE_PORT` + +**Deluge Web UI Port** + +Deluge Web UI port (default: 8112) + +- **Type:** string +- **Default:** `8112` + +#### `DELUGE_PASSWORD` + +**Password** + +Deluge Web UI password (default: deluge) + +- **Type:** string (secret) +- **Default:** _none_ + +#### `DELUGE_CATEGORY` + +**Book Label** + +Label to assign to book downloads in Deluge + +- **Type:** string +- **Default:** `books` + +#### `DELUGE_CATEGORY_AUDIOBOOK` + +**Audiobook Label** + +Label for audiobook downloads. Leave empty to use the book label. + +- **Type:** string +- **Default:** _empty string_ + +#### `PROWLARR_USENET_CLIENT` + +**Usenet Client** + +Choose which usenet client to use + +- **Type:** string (choice) +- **Default:** _empty string_ +- **Options:** None, NZBGet, SABnzbd + +#### `NZBGET_URL` + +**NZBGet URL** + +URL of your NZBGet instance + +- **Type:** string +- **Default:** _none_ + +#### `NZBGET_USERNAME` + +**Username** + +NZBGet control username + +- **Type:** string +- **Default:** `nzbget` + +#### `NZBGET_PASSWORD` + +**Password** + +NZBGet control password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `NZBGET_CATEGORY` + +**Book Category** + +Category to assign to book downloads in NZBGet + +- **Type:** string +- **Default:** `Books` + +#### `NZBGET_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `SABNZBD_URL` + +**SABnzbd URL** + +URL of your SABnzbd instance + +- **Type:** string +- **Default:** _none_ + +#### `SABNZBD_API_KEY` + +**API Key** + +Found in SABnzbd: Config > General > API Key + +- **Type:** string (secret) +- **Default:** _none_ + +#### `SABNZBD_CATEGORY` + +**Book Category** + +Category to assign to book downloads in SABnzbd + +- **Type:** string +- **Default:** `books` + +#### `SABNZBD_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `PROWLARR_USENET_ACTION` + +**NZB Completion Action** + +Copy files into your ingest folder, optionally cleaning up the usenet client + +- **Type:** string (choice) +- **Default:** `move` +- **Options:** Copy and remove from client, Copy (keep in client) + +
diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..bfbd3b2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +# Shelfmark Documentation + +TODO diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..7cd4222 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,3 @@ +# Installation + +TODO diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..10a4dd0 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,3 @@ +# Troubleshooting + +TODO diff --git a/requirements-base.txt b/requirements-base.txt index 5524383..589d648 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -14,4 +14,3 @@ emoji rarfile qbittorrent-api transmission-rpc -deluge-client diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py new file mode 100755 index 0000000..f8ea198 --- /dev/null +++ b/scripts/generate_env_docs.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Generate markdown documentation for environment variables from the settings registry. + +This script extracts all settings that support environment variable configuration +and generates a comprehensive markdown file documenting each option. + +Usage: + python scripts/generate_env_docs.py [--output path/to/output.md] + +The generated documentation includes: +- Environment variable name +- Description +- Type (string, number, boolean, etc.) +- Default value +- Organizational grouping by settings tab/group +""" + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Add project root to path +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root)) + + +def get_field_type_name(field) -> str: + """Get a human-readable type name for a field.""" + from shelfmark.core.settings_registry import ( + CheckboxField, + MultiSelectField, + NumberField, + OrderableListField, + PasswordField, + SelectField, + TextField, + ) + + if isinstance(field, CheckboxField): + return "boolean" + elif isinstance(field, NumberField): + return "number" + elif isinstance(field, SelectField): + return "string (choice)" + elif isinstance(field, MultiSelectField): + return "string (comma-separated)" + elif isinstance(field, OrderableListField): + return "JSON array" + elif isinstance(field, PasswordField): + return "string (secret)" + elif isinstance(field, TextField): + return "string" + else: + return "string" + + +def format_default_value(field) -> str: + """Format the default value for display.""" + default = field.default + + if default is None: + return "_none_" + elif isinstance(default, bool): + return f"`{str(default).lower()}`" + elif isinstance(default, (int, float)): + return f"`{default}`" + elif isinstance(default, str): + if default == "": + return "_empty string_" + return f"`{default}`" + elif isinstance(default, list): + if not default: + return "_empty list_" + # For simple lists, show comma-separated values + if all(isinstance(item, str) for item in default): + return f"`{','.join(default)}`" + # For complex lists (e.g., OrderableListField defaults), summarize + return f"_see UI for defaults_" + else: + return f"`{default}`" + + +def get_select_options(field) -> Optional[List[str]]: + """Get the available options for a SelectField.""" + from shelfmark.core.settings_registry import SelectField + + if not isinstance(field, SelectField): + return None + + options = field.options + if callable(options): + try: + options = options() + except Exception: + return None + + if not options: + return None + + return [opt.get("label", opt.get("value", "")) for opt in options] + + +def _generate_bootstrap_env_docs() -> List[str]: + """Generate documentation for bootstrap environment variables from env.py.""" + # These are environment variables defined in env.py that are used before + # the settings registry is available + bootstrap_vars = [ + { + "name": "CONFIG_DIR", + "description": "Directory for storing configuration files and plugin settings.", + "type": "string (path)", + "default": "/config", + }, + { + "name": "LOG_ROOT", + "description": "Root directory for log files.", + "type": "string (path)", + "default": "/var/log/", + }, + { + "name": "TMP_DIR", + "description": "Staging directory for downloads before moving to destination.", + "type": "string (path)", + "default": "/tmp/shelfmark", + }, + { + "name": "ENABLE_LOGGING", + "description": "Enable file logging to LOG_ROOT/shelfmark/shelfmark.log.", + "type": "boolean", + "default": "true", + }, + { + "name": "FLASK_HOST", + "description": "Host address for the Flask web server.", + "type": "string", + "default": "0.0.0.0", + }, + { + "name": "FLASK_PORT", + "description": "Port number for the Flask web server.", + "type": "number", + "default": "8084", + }, + { + "name": "SESSION_COOKIE_SECURE", + "description": "Enable secure cookies (requires HTTPS).", + "type": "boolean", + "default": "false", + }, + { + "name": "CWA_DB_PATH", + "description": "Path to the Calibre-Web database for authentication integration.", + "type": "string (path)", + "default": "/auth/app.db", + }, + { + "name": "DOCKERMODE", + "description": "Indicates the application is running inside a Docker container.", + "type": "boolean", + "default": "false", + }, + ] + + lines = [ + "## Bootstrap Configuration", + "", + "These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.", + "", + "| Variable | Description | Type | Default |", + "|----------|-------------|------|---------|", + ] + + for var in bootstrap_vars: + lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |") + + lines.append("") + lines.append("
") + lines.append("Detailed descriptions") + lines.append("") + + for var in bootstrap_vars: + lines.append(f"#### `{var['name']}`") + lines.append("") + lines.append(var["description"]) + lines.append("") + lines.append(f"- **Type:** {var['type']}") + lines.append(f"- **Default:** `{var['default']}`") + lines.append("") + + lines.append("
") + lines.append("") + + return lines + + +def generate_env_docs() -> str: + """Generate markdown documentation for all environment variables.""" + # Import settings modules to ensure all settings are registered + import shelfmark.config.settings # noqa: F401 + import shelfmark.release_sources.irc.settings # noqa: F401 + import shelfmark.release_sources.prowlarr.settings # noqa: F401 + import shelfmark.metadata_providers.hardcover # noqa: F401 + import shelfmark.metadata_providers.openlibrary # noqa: F401 + import shelfmark.metadata_providers.googlebooks # noqa: F401 + + from shelfmark.core.settings_registry import ( + ActionButton, + HeadingField, + get_all_groups, + get_all_settings_tabs, + ) + + tabs = get_all_settings_tabs() + groups = {g.name: g for g in get_all_groups()} + + # Organize tabs by group + grouped_tabs: Dict[Optional[str], List] = {None: []} + for group_name in groups: + grouped_tabs[group_name] = [] + + for tab in tabs: + group_name = tab.group + if group_name not in grouped_tabs: + grouped_tabs[group_name] = [] + grouped_tabs[group_name].append(tab) + + # Build markdown output + lines = [ + "# Environment Variables", + "", + "This document lists all configuration options that can be set via environment variables.", + "", + "> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate.", + "", + "## Table of Contents", + "", + ] + + # Generate TOC + toc_entries = [ + "- [Bootstrap Configuration](#bootstrap-configuration)", + ] + + # Ungrouped tabs first + for tab in grouped_tabs.get(None, []): + anchor = tab.display_name.lower().replace(" ", "-") + toc_entries.append(f"- [{tab.display_name}](#{anchor})") + + # Then grouped tabs + for group_name, group in groups.items(): + group_tabs = grouped_tabs.get(group_name, []) + if group_tabs: + anchor = group.display_name.lower().replace(" ", "-") + toc_entries.append(f"- [{group.display_name}](#{anchor})") + for tab in group_tabs: + sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-") + toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})") + + lines.extend(toc_entries) + lines.append("") + lines.append("---") + lines.append("") + + # Add bootstrap environment variables documentation + lines.extend(_generate_bootstrap_env_docs()) + + # Generate documentation for ungrouped tabs + for tab in grouped_tabs.get(None, []): + lines.extend(_generate_tab_docs(tab)) + + # Generate documentation for grouped tabs + for group_name, group in groups.items(): + group_tabs = grouped_tabs.get(group_name, []) + if not group_tabs: + continue + + lines.append(f"## {group.display_name}") + lines.append("") + + for tab in group_tabs: + lines.extend(_generate_tab_docs(tab, group_prefix=group.display_name)) + + return "\n".join(lines) + + +def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]: + """Generate documentation for a single settings tab.""" + from shelfmark.core.settings_registry import ActionButton, HeadingField + + lines = [] + + # Section header + if group_prefix: + lines.append(f"### {group_prefix}: {tab.display_name}") + anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-") + else: + lines.append(f"## {tab.display_name}") + + lines.append("") + + # Collect env-supported fields + env_fields = [] + for field in tab.fields: + # Skip non-value fields + if isinstance(field, (ActionButton, HeadingField)): + continue + + # Skip fields that don't support ENV vars + if not getattr(field, "env_supported", True): + continue + + env_fields.append(field) + + if not env_fields: + lines.append("_No environment variables for this section._") + lines.append("") + return lines + + # Generate table + lines.append("| Variable | Description | Type | Default |") + lines.append("|----------|-------------|------|---------|") + + for field in env_fields: + env_var = field.get_env_var_name() + description = field.description or field.label + # Clean up description for table (remove newlines, escape pipes) + description = description.replace("\n", " ").replace("|", "\\|").strip() + + field_type = get_field_type_name(field) + default = format_default_value(field) + + lines.append(f"| `{env_var}` | {description} | {field_type} | {default} |") + + lines.append("") + + # Add detailed documentation for each field + lines.append("
") + lines.append("Detailed descriptions") + lines.append("") + + for field in env_fields: + env_var = field.get_env_var_name() + lines.append(f"#### `{env_var}`") + lines.append("") + lines.append(f"**{field.label}**") + lines.append("") + + if field.description: + lines.append(field.description) + lines.append("") + + lines.append(f"- **Type:** {get_field_type_name(field)}") + lines.append(f"- **Default:** {format_default_value(field)}") + + if getattr(field, "required", False): + lines.append("- **Required:** Yes") + + if getattr(field, "requires_restart", False): + lines.append("- **Requires restart:** Yes") + + # Show options for SelectField + options = get_select_options(field) + if options: + lines.append(f"- **Options:** {', '.join(options)}") + + # Show constraints for NumberField + from shelfmark.core.settings_registry import NumberField + if isinstance(field, NumberField): + constraints = [] + if field.min_value is not None: + constraints.append(f"min: {field.min_value}") + if field.max_value is not None: + constraints.append(f"max: {field.max_value}") + if constraints: + lines.append(f"- **Constraints:** {', '.join(constraints)}") + + lines.append("") + + lines.append("
") + lines.append("") + + return lines + + +def main(): + parser = argparse.ArgumentParser( + description="Generate markdown documentation for environment variables" + ) + parser.add_argument( + "--output", + "-o", + type=Path, + default=project_root / "docs" / "environment-variables.md", + help="Output file path (default: docs/environment-variables.md)", + ) + parser.add_argument( + "--stdout", + action="store_true", + help="Print to stdout instead of file", + ) + args = parser.parse_args() + + docs = generate_env_docs() + + if args.stdout: + print(docs) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(docs) + print(f"Generated: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_clients.py b/scripts/test_clients.py index 9890ead..afdd899 100755 --- a/scripts/test_clients.py +++ b/scripts/test_clients.py @@ -26,7 +26,7 @@ Web UIs: - rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent) Prerequisites (for running this script locally): - pip install requests transmission-rpc deluge-client qbittorrent-api + pip install requests transmission-rpc qbittorrent-api First-Time Setup: qBittorrent: @@ -38,10 +38,7 @@ First-Time Setup: - No setup needed, credentials pre-configured (admin/admin) Deluge: - 1. Access Web UI at http://localhost:8112 (default password: deluge) - 2. Add auth line to .local/test-clients/deluge/config/auth: - echo "admin:admin:10" >> .local/test-clients/deluge/config/auth - 3. Restart: docker restart test-deluge + - Access Web UI at http://localhost:8112 (default password: deluge) NZBGet: - No setup needed, credentials pre-configured (admin/admin) @@ -80,10 +77,8 @@ CONFIG = { "password": "admin", }, "deluge": { - "host": "localhost", - "port": 58846, - "username": "admin", - "password": "admin", + "url": "http://localhost:8112", + "password": "deluge", }, "rtorrent": { "url": "http://localhost:8000/RPC2", @@ -330,46 +325,79 @@ def test_transmission(): def test_deluge(): - """Test Deluge connection.""" + """Test Deluge Web UI (JSON-RPC) connection.""" + import requests + print("\n" + "=" * 50) print("Testing Deluge") print("=" * 50) + base_url = CONFIG["deluge"]["url"].rstrip("/") + password = CONFIG["deluge"]["password"] + rpc_url = f"{base_url}/json" + + def rpc_call(session: requests.Session, rpc_id: int, method: str, *params): + payload = {"id": rpc_id, "method": method, "params": list(params)} + resp = session.post(rpc_url, json=payload, timeout=10) + resp.raise_for_status() + data = resp.json() + if data.get("error"): + err = data["error"] + if isinstance(err, dict): + raise Exception(err.get("message") or str(err)) + raise Exception(str(err)) + return data.get("result") + try: - from deluge_client import DelugeRPCClient + session = requests.Session() - client = DelugeRPCClient( - host=CONFIG["deluge"]["host"], - port=CONFIG["deluge"]["port"], - username=CONFIG["deluge"]["username"], - password=CONFIG["deluge"]["password"], - ) + # Authenticate to Deluge Web + if rpc_call(session, 1, "auth.login", password) is not True: + raise Exception("Authentication failed (check Deluge Web UI password)") - # Test connection - client.connect() - version = client.call("daemon.info") + # Ensure Deluge Web is connected to a daemon + if rpc_call(session, 2, "web.connected") is not True: + hosts = rpc_call(session, 3, "web.get_hosts") or [] + if not hosts: + raise Exception( + "Deluge Web UI isn't connected to Deluge core (no hosts configured). " + "Add/connect a daemon in Deluge Web UI → Connection Manager." + ) + + host_id = hosts[0][0] + for entry in hosts: + if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: + host_id = entry[0] + break + + rpc_call(session, 4, "web.connect", host_id) + + if rpc_call(session, 5, "web.connected") is not True: + raise Exception( + "Deluge Web UI couldn't connect to Deluge core. " + "Check Deluge Web UI → Connection Manager." + ) + + version = rpc_call(session, 6, "daemon.info") print(f" Connected to Deluge {version}") - # Get torrent list - torrents = client.call("core.get_torrents_status", {}, ["name"]) + torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {} print(f" Active torrents: {len(torrents)}") # Test adding a torrent (then remove it) print(" Testing add/remove torrent...") - torrent_id = client.call("core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}) + torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}) if torrent_id: + torrent_id = str(torrent_id) print(f" Added test torrent: {torrent_id[:20]}...") - # Get status - status = client.call("core.get_torrent_status", torrent_id, ["state", "progress"]) - state = status.get(b"state", b"unknown") - if isinstance(state, bytes): - state = state.decode() - print(f" Status: {state}") + status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {} + state = status.get("state", "unknown") if isinstance(status, dict) else "unknown" + progress = status.get("progress", 0) if isinstance(status, dict) else 0 + print(f" Status: {state} ({progress:.1f}%)") - # Remove it - client.call("core.remove_torrent", torrent_id, True) + rpc_call(session, 10, "core.remove_torrent", torrent_id, True) print(" Removed test torrent") else: print(" WARNING: Could not add test torrent") @@ -377,19 +405,17 @@ def test_deluge(): print(" SUCCESS: Deluge is working!") return True - except ImportError: - print(" ERROR: deluge-client not installed") - print(" Run: pip install deluge-client") + except requests.exceptions.ConnectionError: + print(" ERROR: Could not connect to Deluge Web UI") + print(" Is the container running? docker ps | grep deluge") + return False + except requests.exceptions.Timeout: + print(" ERROR: Deluge Web UI connection timed out") return False except Exception as e: print(f" ERROR: {e}") - if "Connection refused" in str(e): - print(" Is the container running? docker ps | grep deluge") - elif "Bad login" in str(e) or "auth" in str(e).lower(): - print("\n Deluge auth setup required:") - print(" 1. Add 'admin:admin:10' to .local/test-clients/deluge/config/auth") - print(" 2. Restart: docker restart test-deluge") - print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)") + if "auth" in str(e).lower() or "login" in str(e).lower(): + print(" Check Deluge Web UI password (default: deluge)") return False def test_rtorrent(): diff --git a/shelfmark/config/booklore_settings.py b/shelfmark/config/booklore_settings.py new file mode 100644 index 0000000..3d103dd --- /dev/null +++ b/shelfmark/config/booklore_settings.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import Any + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.download.outputs.booklore import ( + BookloreConfig, + BookloreError, + booklore_list_libraries, + booklore_login, +) + +logger = setup_logger(__name__) + +_BOOKLORE_OPTIONS_CACHE: dict[str, Any] = { + "key": None, + "library_options": [], + "path_options": [], +} + + +def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str: + return f"{base_url}|{username}|{hash(password)}" + + +def _get_booklore_select_options( + base_url: str, + username: str, + password: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + # library_id/path_id are not used for login/library listing + booklore_config = BookloreConfig( + base_url=base_url.rstrip("/"), + username=username, + password=password, + library_id=1, + path_id=1, + verify_tls=True, + refresh_after_upload=True, + ) + + token = booklore_login(booklore_config) + libraries = booklore_list_libraries(booklore_config, token) or [] + logger.debug("Booklore libraries response: %s", libraries) + + library_options: list[dict[str, Any]] = [] + path_options: list[dict[str, Any]] = [] + + for library in libraries: + if not isinstance(library, dict): + continue + + library_id = library.get("id") + if library_id is None: + continue + + library_name = str(library.get("name") or f"Library {library_id}") + library_id_str = str(library_id) + + library_options.append({"value": library_id_str, "label": library_name}) + + paths = library.get("paths") or [] + if not isinstance(paths, list): + continue + + for path in paths: + if not isinstance(path, dict): + continue + + path_id = path.get("id") + if path_id is None: + continue + + path_label = str(path.get("path") or f"Path {path_id}") + path_options.append( + { + "value": str(path_id), + "label": f"{library_name}: {path_label}", + "childOf": library_id_str, + } + ) + + logger.debug( + "Booklore options built: libraries=%d paths=%d", + len(library_options), + len(path_options), + ) + + cache_key = _get_booklore_cache_key(base_url, username, password) + _BOOKLORE_OPTIONS_CACHE.update( + { + "key": cache_key, + "library_options": library_options, + "path_options": path_options, + } + ) + + return library_options, path_options + + +def _get_booklore_cached_options( + base_url: str, + username: str, + password: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + cache_key = _get_booklore_cache_key(base_url, username, password) + if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key: + return ( + _BOOKLORE_OPTIONS_CACHE.get("library_options", []), + _BOOKLORE_OPTIONS_CACHE.get("path_options", []), + ) + + return _get_booklore_select_options(base_url, username, password) + + +def get_booklore_library_options() -> list[dict[str, Any]]: + """Build Booklore library options dynamically from config.""" + if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore": + return [] + + base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/") + username = str(config.get("BOOKLORE_USERNAME", "") or "").strip() + password = config.get("BOOKLORE_PASSWORD", "") or "" + + if not base_url or not username or not password: + return [] + + cache_key = _get_booklore_cache_key(base_url, username, password) + + try: + library_options, _ = _get_booklore_cached_options(base_url, username, password) + return library_options + except Exception as exc: + logger.error(f"Failed to fetch Booklore libraries: {exc}") + if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key: + return _BOOKLORE_OPTIONS_CACHE.get("library_options", []) + return [] + + +def get_booklore_path_options() -> list[dict[str, Any]]: + """Build Booklore path options dynamically from config.""" + if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore": + return [] + + base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/") + username = str(config.get("BOOKLORE_USERNAME", "") or "").strip() + password = config.get("BOOKLORE_PASSWORD", "") or "" + + if not base_url or not username or not password: + return [] + + cache_key = _get_booklore_cache_key(base_url, username, password) + + try: + _, path_options = _get_booklore_cached_options(base_url, username, password) + return path_options + except Exception as exc: + logger.error(f"Failed to fetch Booklore paths: {exc}") + if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key: + return _BOOKLORE_OPTIONS_CACHE.get("path_options", []) + return [] + + +def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test the Booklore connection using current form values.""" + current_values = current_values or {} + + def _get_value(key: str, default: Any = None) -> Any: + value = current_values.get(key) + if value not in (None, ""): + return value + if default is None: + return config.get(key) + return config.get(key, default) + + base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/") + username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip() + password = _get_value("BOOKLORE_PASSWORD", "") or "" + + if not base_url: + return {"success": False, "message": "Booklore URL is required"} + if not username: + return {"success": False, "message": "Booklore username is required"} + if not password: + return {"success": False, "message": "Booklore password is required"} + + try: + library_options, _ = _get_booklore_select_options(base_url, username, password) + + message = "Connected to Booklore" + if library_options: + message = f"Connected to Booklore ({len(library_options)} libraries)" + + return {"success": True, "message": message} + except BookloreError as exc: + return {"success": False, "message": str(exc)} diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 352696a..f2bf678 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -3,8 +3,14 @@ import os from pathlib import Path import json +from typing import Any from shelfmark.config import env +from shelfmark.config.booklore_settings import ( + get_booklore_library_options, + get_booklore_path_options, + test_booklore_connection, +) from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) @@ -53,6 +59,8 @@ def _log_external_bypasser_warning() -> None: from shelfmark.core.settings_registry import ( register_settings, register_group, + register_on_save, + load_config_file, TextField, PasswordField, NumberField, @@ -150,6 +158,8 @@ def _get_release_source_options(): if source.get("can_be_default", True) ] + + _LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE] def _get_aa_base_url_options(): @@ -277,7 +287,6 @@ def general_settings(): label="Audiobook Library URL", description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.", placeholder="http://audiobookshelf:8080", - env_supported=False, ), HeadingField( key="search_defaults_heading", @@ -341,7 +350,6 @@ def search_mode_settings(): description="Default sort order for search results.", options=_AA_SORT_OPTIONS, default="relevance", - env_supported=False, # UI-only setting show_when={"field": "SEARCH_MODE", "value": "direct"}, ), HeadingField( @@ -372,7 +380,6 @@ def search_mode_settings(): 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 default="direct_download", - env_supported=False, # UI-only setting, not configurable via ENV show_when={"field": "SEARCH_MODE", "value": "universal"}, ), ] @@ -511,6 +518,40 @@ def network_settings(): ] +def _contains_path_separators(value: Any) -> bool: + return isinstance(value, str) and ("/" in value or "\\" in value) + + +def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: + """Validate download settings before persisting.""" + existing = load_config_file("downloads") + effective: dict[str, Any] = dict(existing) + effective.update(values) + + # 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": + template = effective.get("TEMPLATE_RENAME", "") + if _contains_path_separators(template): + return { + "error": True, + "message": "Books Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.", + "values": values, + } + + # Audiobooks are always folder output. + if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename": + template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "") + if _contains_path_separators(template): + return { + "error": True, + "message": "Audiobooks Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.", + "values": values, + } + + return {"error": False, "values": values} + + @register_settings("downloads", "Downloads", icon="folder", order=5) def download_settings(): """Configure download behavior and file locations.""" @@ -522,6 +563,24 @@ def download_settings(): title="Books", description="Configure where ebooks, comics, and magazines are saved.", ), + SelectField( + key="BOOKS_OUTPUT_MODE", + label="Output Mode", + description="Choose where completed book files are sent.", + options=[ + { + "value": "folder", + "label": "Folder", + "description": "Save files to the destination folder", + }, + { + "value": "booklore", + "label": "Booklore (API)", + "description": "Upload files directly to Booklore", + }, + ], + default="folder", + ), TextField( key="DESTINATION", label="Destination", @@ -529,6 +588,10 @@ def download_settings(): default="/books", required=True, env_var="INGEST_DIR", # Legacy env var name for backwards compatibility + show_when={ + "field": "BOOKS_OUTPUT_MODE", + "value": "folder", + }, ), SelectField( key="FILE_ORGANIZATION", @@ -552,15 +615,22 @@ def download_settings(): }, ], default="rename", + show_when={ + "field": "BOOKS_OUTPUT_MODE", + "value": "folder", + }, ), # Rename mode template - filename only TextField( key="TEMPLATE_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}", + description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.", default="{Author} - {Title} ({Year})", placeholder="{Author} - {Title} ({Year})", - show_when={"field": "FILE_ORGANIZATION", "value": "rename"}, + show_when=[ + {"field": "BOOKS_OUTPUT_MODE", "value": "folder"}, + {"field": "FILE_ORGANIZATION", "value": "rename"}, + ], ), # Organize mode template - folders allowed TextField( @@ -569,7 +639,10 @@ def download_settings(): description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}", default="{Author}/{Title} ({Year})", placeholder="{Author}/{Series/}{Title} ({Year})", - show_when={"field": "FILE_ORGANIZATION", "value": "organize"}, + show_when=[ + {"field": "BOOKS_OUTPUT_MODE", "value": "folder"}, + {"field": "FILE_ORGANIZATION", "value": "organize"}, + ], ), CheckboxField( key="HARDLINK_TORRENTS", @@ -577,6 +650,63 @@ def download_settings(): description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.", default=False, universal_only=True, + show_when={ + "field": "BOOKS_OUTPUT_MODE", + "value": "folder", + }, + ), + HeadingField( + key="booklore_heading", + title="Booklore", + description="Upload books directly to Booklore via API. Audiobooks always use folder mode.", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + TextField( + key="BOOKLORE_HOST", + label="Booklore URL", + description="Base URL of your Booklore instance", + placeholder="http://booklore:6060", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + TextField( + key="BOOKLORE_USERNAME", + label="Username", + description="Booklore account username", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + PasswordField( + key="BOOKLORE_PASSWORD", + label="Password", + description="Booklore account password", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + SelectField( + key="BOOKLORE_LIBRARY_ID", + label="Library", + description="Booklore library to upload into.", + options=get_booklore_library_options, + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + SelectField( + key="BOOKLORE_PATH_ID", + label="Path", + description="Booklore library path for uploads.", + options=get_booklore_path_options, + required=True, + filter_by_field="BOOKLORE_LIBRARY_ID", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + ), + ActionButton( + key="test_booklore", + label="Test Connection", + description="Verify your Booklore configuration", + style="primary", + callback=test_booklore_connection, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, ), # === AUDIOBOOKS SECTION === @@ -610,7 +740,7 @@ def download_settings(): TextField( key="TEMPLATE_AUDIOBOOK_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}", + description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.", default="{Author} - {Title}", placeholder="{Author} - {Title}{ - Part }{PartNumber}", show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"}, @@ -644,14 +774,12 @@ def download_settings(): label="Auto-Open Downloads Sidebar", description="Automatically open the downloads sidebar when a new download is queued.", default=False, - env_supported=False, # UI-only setting ), CheckboxField( key="DOWNLOAD_TO_BROWSER", label="Download to Browser", description="Automatically download completed files to your browser.", default=False, - env_supported=False, # UI-only setting ), NumberField( key="MAX_CONCURRENT_DOWNLOADS", @@ -673,6 +801,10 @@ def download_settings(): ] +# Register the on_save handler for this tab +register_on_save("downloads", _on_save_downloads) + + def _get_fast_source_options(): """Fast download sources - display only, not configurable.""" from shelfmark.core.config import config @@ -777,7 +909,6 @@ def download_source_settings(): description="Always tried first, no waiting or bypass required.", options=_get_fast_source_options, default=_get_fast_source_defaults(), - env_supported=False, ), OrderableListField( key="SOURCE_PRIORITY", diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 398b0f0..5c583fb 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -24,7 +24,7 @@ class FieldBase: env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only) disabled: bool = False # Whether field is disabled/greyed out disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} + show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} requires_restart: bool = False # Whether changing this setting requires a container restart universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) @@ -71,6 +71,7 @@ class SelectField(FieldBase): """Single-choice dropdown.""" # Options can be a list or a callable that returns a list (for lazy evaluation) options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable + filter_by_field: Optional[str] = None # Field key whose value filters options via childOf property @dataclass @@ -99,10 +100,10 @@ class ActionButton: label: str # Button text description: str = "" # Help text style: str = "default" # "default", "primary", "danger" - callback: Optional[Callable[[], Dict[str, Any]]] = None # Returns {"success": bool, "message": str} + callback: Optional[Callable[..., Dict[str, Any]]] = None # Returns {"success": bool, "message": str} disabled: bool = False # Whether button is disabled/greyed out disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} + show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} def get_field_type(self) -> str: @@ -122,7 +123,7 @@ class HeadingField: description: str = "" # Description text (supports markdown-style links) link_url: str = "" # Optional URL for a link link_text: str = "" # Text for the link (defaults to URL if not provided) - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} + show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) def get_field_type(self) -> str: @@ -562,7 +563,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T """ # HeadingField has a different structure - handle separately if isinstance(field, HeadingField): - result = { + result: Dict[str, Any] = { "key": field.key, "type": field.get_field_type(), "title": field.title, @@ -577,7 +578,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T result["universalOnly"] = True return result - result = { + result: Dict[str, Any] = { "key": field.key, "label": field.label, "type": field.get_field_type(), @@ -613,6 +614,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T result["options"] = options if field.default is not None: result["default"] = field.default + if field.filter_by_field: + result["filterByField"] = field.filter_by_field elif isinstance(field, MultiSelectField): # Support callable options for lazy evaluation (avoids circular imports) options = field.options() if callable(field.options) else field.options @@ -628,6 +631,23 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T if include_value and not isinstance(field, (ActionButton, HeadingField)): value = get_setting_value(field, tab_name) + + # Ensure select values are serialized as strings so the frontend can + # reliably match against string option values. + if isinstance(field, SelectField) and value is not None: + value = str(value) + elif isinstance(field, MultiSelectField): + if value is None: + value = [] + elif isinstance(value, list): + value = [str(v) for v in value] + elif isinstance(value, str): + # Support legacy/manual configs where MultiSelect values were saved + # as comma-separated strings. + value = [v.strip() for v in value.split(",") if v.strip()] + else: + value = [] + result["value"] = value if value is not None else "" result["fromEnv"] = is_value_from_env(field) @@ -696,7 +716,7 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict try: # Check if callback accepts current_values parameter sig = inspect.signature(field.callback) - if 'current_values' in sig.parameters: + if "current_values" in sig.parameters: return field.callback(current_values=current_values or {}) else: return field.callback() @@ -816,16 +836,22 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: # Save to config file if save_config_file(tab_name, values_to_save): # Refresh the config singleton so live settings take effect immediately + config_obj = None try: - from shelfmark.core.config import config - config.refresh() + from shelfmark.core.config import config as config_obj + + config_obj.refresh() except ImportError: - pass # Config module not yet available during initial setup + config_obj = None # Config module not yet available during initial setup # Apply DNS settings changes live (network tab) dns_keys = {"CUSTOM_DNS", "CUSTOM_DNS_MANUAL", "USE_DOH"} - if tab_name == "network" and dns_keys.intersection(values_to_save.keys()): - _apply_dns_settings(config) + if ( + config_obj is not None + and tab_name == "network" + and dns_keys.intersection(values_to_save.keys()) + ): + _apply_dns_settings(config_obj) # Sync metadata provider selection when a provider's enabled state changes tab = get_settings_tab(tab_name) diff --git a/shelfmark/download/archive.py b/shelfmark/download/archive.py index 125b7c7..817a05c 100644 --- a/shelfmark/download/archive.py +++ b/shelfmark/download/archive.py @@ -3,107 +3,20 @@ import os import shutil import zipfile -from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Tuple from shelfmark.core.logger import setup_logger -from shelfmark.core.config import config -from shelfmark.core.naming import parse_naming_template, sanitize_filename +from shelfmark.download.postprocess.policy import ( + get_supported_audiobook_formats, + get_supported_formats, +) from shelfmark.core.utils import is_audiobook as check_audiobook -from shelfmark.download.fs import atomic_write, atomic_move +from shelfmark.download.fs import atomic_write logger = setup_logger(__name__) -def _get_supported_formats() -> List[str]: - """Get current supported formats from config singleton.""" - formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]) - # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) - if isinstance(formats, str): - return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] - return [fmt.lower() for fmt in formats] - - -def _get_supported_audiobook_formats() -> List[str]: - """Get current supported audiobook formats from config singleton.""" - formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"]) - # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) - if isinstance(formats, str): - return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] - return [fmt.lower() for fmt in formats] - - -def _get_file_organization(is_audiobook: bool) -> str: - """Get the file organization mode for the content type.""" - key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION" - mode = config.get(key, "rename") - - # Handle legacy settings migration - if mode not in ("none", "rename", "organize"): - legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE" - legacy_mode = config.get(legacy_key, "ingest") - if legacy_mode == "library": - return "organize" - if config.get("USE_BOOK_TITLE", True): - return "rename" - return "none" - - return mode - - -def _get_template(is_audiobook: bool, organization_mode: str) -> str: - """Get the template for the content type and organization mode.""" - # Determine the correct key based on content type and organization mode - if is_audiobook: - if organization_mode == "organize": - key = "TEMPLATE_AUDIOBOOK_ORGANIZE" - else: - key = "TEMPLATE_AUDIOBOOK_RENAME" - else: - if organization_mode == "organize": - key = "TEMPLATE_ORGANIZE" - else: - key = "TEMPLATE_RENAME" - - template = config.get(key, "") - - # Fallback to legacy keys if new keys are empty - if not template: - legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE" - template = config.get(legacy_key, "") - - if not template: - legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE" - template = config.get(legacy_key, "") - - if not template: - if organization_mode == "organize": - return "{Author}/{Title} ({Year})" - return "{Author} - {Title} ({Year})" - - return template - - -def _build_filename_from_task(task, extension: str, organization_mode: str) -> str: - """Build a filename from task metadata using the configured template.""" - is_audiobook = check_audiobook(task.content_type) - - template = _get_template(is_audiobook, organization_mode) - metadata = { - "Author": task.author, - "Title": task.title, - "Subtitle": getattr(task, 'subtitle', None), - "Year": task.year, - "Series": getattr(task, 'series_name', None), - "SeriesPosition": getattr(task, 'series_position', None), - } - - filename = parse_naming_template(template, metadata) - if filename: - return f"{sanitize_filename(filename)}.{extension}" - return "" - # Check for rarfile availability at module load try: import rarfile @@ -142,9 +55,9 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b """Check if file matches user's supported formats setting based on content type.""" ext = file_path.suffix.lower().lstrip(".") if check_audiobook(content_type): - supported_formats = _get_supported_audiobook_formats() + supported_formats = get_supported_audiobook_formats() else: - supported_formats = _get_supported_formats() + supported_formats = get_supported_formats() return ext in supported_formats @@ -225,6 +138,21 @@ def extract_archive( return matched_files, warnings, rejected_files +def extract_archive_raw( + archive_path: Path, + output_dir: Path, +) -> Tuple[List[Path], List[str]]: + """Extract archive without filtering (returns all extracted files).""" + suffix = archive_path.suffix.lower().lstrip(".") + + if suffix == "zip": + return _extract_zip(archive_path, output_dir) + if suffix == "rar": + return _extract_rar(archive_path, output_dir) + + raise ArchiveExtractionError(f"Unsupported archive format: {suffix}") + + def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]: """Extract files from ZipFile or RarFile to output_dir with security checks.""" extracted_files = [] @@ -309,141 +237,3 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List raise ArchiveExtractionError(f"Permission denied: {e}") -@dataclass -class ArchiveResult: - """Result of archive processing.""" - - success: bool - final_paths: List[Path] - message: str - error: Optional[str] = None - - -def process_archive( - archive_path: Path, - temp_dir: Path, - ingest_dir: Path, - archive_id: str, - task: Optional["DownloadTask"] = None, -) -> ArchiveResult: - """Extract archive, filter to supported formats, move to ingest directory.""" - extract_dir = temp_dir / f"extract_{archive_id}" - content_type = task.content_type if task else None - is_audiobook = check_audiobook(content_type) - file_type_label = "audiobook" if is_audiobook else "book" - - try: - # Create temp extraction directory - os.makedirs(extract_dir, exist_ok=True) - os.makedirs(ingest_dir, exist_ok=True) - - # Extract to temp directory (filters based on content type) - extracted_files, warnings, rejected_files = extract_archive(archive_path, extract_dir, content_type) - - if not extracted_files: - # Clean up and return error - shutil.rmtree(extract_dir, ignore_errors=True) - archive_path.unlink(missing_ok=True) - - if rejected_files: - # Found files but they weren't in supported formats - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) - rejected_list = ", ".join(rejected_exts) - supported_formats = _get_supported_audiobook_formats() if is_audiobook else _get_supported_formats() - logger.warning( - f"Found {len(rejected_files)} {file_type_label}(s) in archive but format not supported. " - f"Rejected: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}" - ) - return ArchiveResult( - success=False, - final_paths=[], - message="", - error=f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). Enable in Settings > Formats.", - ) - - return ArchiveResult( - success=False, - final_paths=[], - message="", - error=f"No {file_type_label} files found in archive", - ) - - for warning in warnings: - logger.debug(warning) - - logger.info(f"Extracted {len(extracted_files)} {file_type_label} file(s) from archive") - - # Move book files to ingest folder - final_paths = [] - - # Determine file organization mode - is_audiobook = check_audiobook(task.content_type) if task else False - organization_mode = _get_file_organization(is_audiobook) if task else "none" - - for extracted_file in extracted_files: - # For multi-file archives (book packs, series), always preserve original filenames - # since metadata title only applies to the searched book, not the whole pack. - # For single files, respect FILE_ORGANIZATION setting. - if len(extracted_files) == 1 and organization_mode != "none" and task: - # Use the extracted file's actual extension, not the archive's extension - extracted_format = extracted_file.suffix.lower().lstrip('.') - filename = _build_filename_from_task(task, extracted_format, organization_mode) - if not filename: - filename = extracted_file.name - else: - filename = extracted_file.name - - dest_path = ingest_dir / filename - final_path = atomic_move(extracted_file, dest_path) - final_paths.append(final_path) - logger.debug(f"Moved to ingest: {final_path.name}") - - # Clean up temp extraction directory and archive - shutil.rmtree(extract_dir, ignore_errors=True) - archive_path.unlink(missing_ok=True) - - # Build success message with format info - formats = [p.suffix.lstrip(".").upper() for p in final_paths] - if len(formats) == 1: - message = f"Complete ({formats[0]})" - else: - message = f"Complete ({len(formats)} files)" - - return ArchiveResult( - success=True, - final_paths=final_paths, - message=message, - ) - - except PasswordProtectedError: - logger.error(f"Password-protected archive: {archive_path.name}") - shutil.rmtree(extract_dir, ignore_errors=True) - archive_path.unlink(missing_ok=True) - return ArchiveResult( - success=False, - final_paths=[], - message="", - error="Archive is password protected", - ) - - except CorruptedArchiveError as e: - logger.error(f"Corrupted archive: {e}") - shutil.rmtree(extract_dir, ignore_errors=True) - archive_path.unlink(missing_ok=True) - return ArchiveResult( - success=False, - final_paths=[], - message="", - error=f"Corrupted archive: {e}", - ) - - except ArchiveExtractionError as e: - logger.error(f"Archive extraction failed: {e}") - shutil.rmtree(extract_dir, ignore_errors=True) - archive_path.unlink(missing_ok=True) - return ArchiveResult( - success=False, - final_paths=[], - message="", - error=f"Extraction failed: {e}", - ) diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index d6bc5da..f33330a 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -8,13 +8,47 @@ import errno import os import shutil import subprocess +import time from pathlib import Path from shelfmark.core.logger import setup_logger +from shelfmark.download.permissions_debug import log_transfer_permission_context logger = setup_logger(__name__) + +_VERIFY_IO_WAIT_SECONDS = 3.0 + + +def _verify_transfer_size( + dest: Path, + expected_size: int, + action: str, +) -> None: + """Verify file transfer completed successfully. + + Some filesystems (especially remote NAS/CIFS/NFS) can report stale sizes briefly + after large writes. Do a second stat after a short delay before declaring failure. + """ + actual_size = dest.stat().st_size + if actual_size == expected_size: + return + + logger.debug( + f"File {action} size mismatch, waiting for filesystem sync: {dest} " + f"({actual_size} != {expected_size})" + ) + time.sleep(_VERIFY_IO_WAIT_SECONDS) + + actual_size = dest.stat().st_size + if actual_size != expected_size: + raise IOError( + f"File {action} incomplete, data loss may have occurred. " + f"'{dest}' was {actual_size} bytes instead of expected {expected_size}." + ) + + def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path: """Write data to a file with atomic collision detection. @@ -40,7 +74,7 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path: try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}" try: # O_CREAT | O_EXCL fails atomically if file exists - fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666) try: os.write(fd, data) finally: @@ -61,7 +95,7 @@ def _is_permission_error(e: Exception) -> bool: def _system_op(op: str, source: Path, dest: Path) -> None: """Execute system command (mv or cp) as final fallback.""" - logger.info(f"Attempting system {op} as final fallback: {source} -> {dest}") + logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest) subprocess.run( [op, "-f", str(source), str(dest)], check=True, @@ -71,32 +105,38 @@ def _system_op(op: str, source: Path, dest: Path) -> None: def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None: - """Handle NFS permission errors by falling back to copyfile -> system op.""" + """Handle NFS/SMB permission errors by falling back to copyfile -> system op.""" + expected_size = source.stat().st_size + try: # Fallback 1: copy content only shutil.copyfile(str(source), str(dest)) - + _verify_transfer_size(dest, expected_size, "copy") + if is_move: - # Verify copy success before removing source - if dest.exists() and dest.stat().st_size == source.stat().st_size: - source.unlink() - return - else: - raise IOError(f"Copy verification failed for {source} -> {dest}") + source.unlink() + return except Exception as copy_error: # Clean up failed copy attempt if it exists - if dest.exists(): - dest.unlink(missing_ok=True) - - logger.error(f"Fallback copyfile failed: {copy_error}") - + dest.unlink(missing_ok=True) + + if _is_permission_error(copy_error): + log_transfer_permission_context("nfs_fallback_copyfile", source=source, dest=dest, error=copy_error) + logger.error("Fallback copyfile failed (%s -> %s): %s", source, dest, copy_error) + # Fallback 2: system command op = "mv" if is_move else "cp" try: _system_op(op, source, dest) + # Best-effort verify after external command. + if dest.exists(): + _verify_transfer_size(dest, expected_size, op) + if is_move: + source.unlink(missing_ok=True) except subprocess.CalledProcessError as sys_error: - logger.error(f"System {op} failed: {sys_error.stderr}") + log_transfer_permission_context("nfs_fallback_system", source=source, dest=dest, error=sys_error) + logger.error("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr) dest.unlink(missing_ok=True) raise @@ -143,36 +183,75 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> # Race condition: file created between exists() check and rename() continue except OSError as e: - # Cross-filesystem - fall back to exclusive create + move + # Cross-filesystem - fall back to exclusive create + verified copy + delete. if e.errno != errno.EXDEV: raise + + expected_size = source_path.stat().st_size + try: - fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + # Claim destination path atomically. + fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666) os.close(fd) + + # Copy to a temp file first, then replace to avoid partial files. + temp_path = try_path.parent / f".{try_path.name}.tmp" try: - shutil.move(str(source_path), str(try_path)) + try: + shutil.copy2(str(source_path), str(temp_path)) + except (PermissionError, OSError) as copy_error: + if _is_permission_error(copy_error): + logger.debug( + "Permission error during move-copy, falling back to copyfile (%s -> %s): %s", + source_path, + temp_path, + copy_error, + ) + _perform_nfs_fallback(source_path, temp_path, is_move=False) + else: + raise + + temp_path.replace(try_path) + _verify_transfer_size(try_path, expected_size, "move") + source_path.unlink() + if attempt > 0: logger.info(f"File collision resolved: {try_path.name}") return try_path + except Exception: - # Clean up the placeholder if move failed - if try_path.exists() and try_path.stat().st_size == 0: - try_path.unlink(missing_ok=True) + try_path.unlink(missing_ok=True) + temp_path.unlink(missing_ok=True) raise + except FileExistsError: continue except (PermissionError, OSError) as e: - # Handle NFS permission errors (e.g. inability to set metadata) if _is_permission_error(e): - logger.debug(f"Permission error during move, falling back to copyfile: {e}") + log_transfer_permission_context( + "atomic_move", + source=source_path, + dest=try_path, + error=e, + ) + logger.debug( + "Permission error during move, falling back to copyfile (%s -> %s): %s", + source_path, + try_path, + e, + ) try: _perform_nfs_fallback(source_path, try_path, is_move=True) if attempt > 0: logger.info(f"File collision resolved (fallback): {try_path.name}") return try_path except Exception as fallback_error: - # Fallback failed, chain exceptions for better debugging - logger.error(f"NFS fallback also failed: {fallback_error}") + logger.error( + "NFS fallback also failed (%s -> %s): %s", + source_path, + try_path, + fallback_error, + ) raise e from fallback_error raise @@ -206,6 +285,23 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100) return try_path except FileExistsError: continue + except OSError as e: + if _is_permission_error(e) or e.errno in (errno.EXDEV, errno.EMLINK): + if _is_permission_error(e): + log_transfer_permission_context( + "atomic_hardlink", + source=source_path, + dest=try_path, + error=e, + ) + logger.debug( + "Hardlink failed (%s), falling back to copy: %s -> %s", + e, + source_path, + dest_path, + ) + return atomic_copy(source_path, dest_path, max_attempts=max_attempts) + raise raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}") @@ -235,7 +331,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}" try: # Atomically claim the destination by creating an exclusive file - fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666) os.close(fd) # Copy to temp file first, then replace to avoid partial files @@ -246,16 +342,33 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> except (PermissionError, OSError) as e: # Handle NFS permission errors immediately here if _is_permission_error(e): - logger.debug(f"Permission error during copy, falling back to copyfile: {e}") + log_transfer_permission_context( + "atomic_copy", + source=source_path, + dest=temp_path, + error=e, + ) + logger.debug( + "Permission error during copy, falling back to copyfile (%s -> %s): %s", + source_path, + temp_path, + e, + ) try: _perform_nfs_fallback(source_path, temp_path, is_move=False) except Exception as fallback_error: - logger.error(f"NFS fallback also failed: {fallback_error}") + logger.error( + "NFS fallback also failed (%s -> %s): %s", + source_path, + temp_path, + fallback_error, + ) raise e from fallback_error else: raise temp_path.replace(try_path) + _verify_transfer_size(try_path, source_path.stat().st_size, "copy") if attempt > 0: logger.info(f"File collision resolved: {try_path.name}") return try_path diff --git a/shelfmark/download/network.py b/shelfmark/download/network.py index d67a6a7..a27842a 100644 --- a/shelfmark/download/network.py +++ b/shelfmark/download/network.py @@ -279,9 +279,33 @@ def _decode_port(port: Union[str, bytes, int, None]) -> int: return int(port) def _is_local_address(host_str: str) -> bool: - """Check if an address is local or private and should bypass custom DNS.""" - if host_str == 'localhost': + """Check if an address is local/private and should bypass custom DNS. + + Returns True for: + - 'localhost' + - Private/loopback/link-local IP addresses + - Simple hostnames without a dot (e.g., 'booklore', 'prowlarr') - likely Docker service names + - Hostnames ending in common internal TLDs (.local, .internal, .lan, .home, .docker) + """ + if not host_str: + return False + + host_lower = host_str.lower() + + # Check for localhost + if host_lower == 'localhost': return True + + # Check for simple hostnames (no dot = likely internal Docker/container name) + if '.' not in host_str: + return True + + # Check for common internal TLDs + internal_tlds = ('.local', '.internal', '.lan', '.home', '.docker', '.localdomain') + if any(host_lower.endswith(tld) for tld in internal_tlds): + return True + + # Check for private/loopback/link-local IP addresses try: addr = ipaddress.ip_address(host_str) return addr.is_private or addr.is_loopback or addr.is_link_local diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 9417b50..df28f2a 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -4,11 +4,8 @@ Two-stage architecture: handlers stage to TMP_DIR, orchestrator moves to INGEST_ with archive extraction and custom script support. """ -import hashlib import os import random -import shutil -import subprocess import threading import time from concurrent.futures import Future, ThreadPoolExecutor @@ -16,261 +13,25 @@ from pathlib import Path from threading import Event, Lock from typing import Any, Dict, List, Optional, Tuple -from shelfmark.release_sources import direct_download -from shelfmark.release_sources.direct_download import SearchUnavailable from shelfmark.core.config import config -from shelfmark.config.env import TMP_DIR -from shelfmark.core.utils import get_ingest_dir, get_destination, get_aa_content_type_dir, is_audiobook as check_audiobook, transform_cover_url -from shelfmark.core.naming import build_library_path, same_filesystem, assign_part_numbers, parse_naming_template, sanitize_filename -from shelfmark.download.archive import ( - is_archive, - process_archive, - _get_file_organization, - _get_template, - _get_supported_formats as _get_book_formats, - _get_supported_audiobook_formats, -) -from shelfmark.release_sources import get_handler, get_source_display_name from shelfmark.core.logger import setup_logger from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode from shelfmark.core.queue import book_queue +from shelfmark.core.utils import transform_cover_url +from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path +from shelfmark.download.postprocess.router import post_process_download +from shelfmark.release_sources import direct_download, get_handler, get_source_display_name +from shelfmark.release_sources.direct_download import SearchUnavailable logger = setup_logger(__name__) # ============================================================================= -# Staging Directory Helpers +# Task Download and Processing # ============================================================================= -# Handlers should use these to get paths in the staging area. -# The orchestrator handles moving staged files to the ingest folder. - -def get_staging_dir() -> Path: - """Get the staging directory for downloads.""" - TMP_DIR.mkdir(parents=True, exist_ok=True) - return TMP_DIR - - -def get_staging_path(task_id: str, extension: str) -> Path: - """Get a staging path for a download.""" - staging_dir = get_staging_dir() - # Hash task_id in case it contains invalid filename chars (e.g., Prowlarr URLs) - safe_id = hashlib.md5(task_id.encode()).hexdigest()[:16] - return staging_dir / f"{safe_id}.{extension.lstrip('.')}" - - -def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: - """Stage a file for ingest processing. Use copy=True for torrents to preserve seeding.""" - staging_dir = get_staging_dir() - # Stage with original filename, add counter suffix if collision - staged_path = staging_dir / source_path.name - if staged_path.exists(): - counter = 1 - while staged_path.exists(): - staged_path = staging_dir / f"{source_path.stem}_{counter}{source_path.suffix}" - counter += 1 - - if copy: - shutil.copy2(str(source_path), str(staged_path)) - logger.debug(f"Copied to staging: {source_path} -> {staged_path}") - else: - shutil.move(str(source_path), str(staged_path)) - logger.debug(f"Moved to staging: {source_path} -> {staged_path}") - - return staged_path - - -def _should_hardlink(task: DownloadTask) -> bool: - """Check if hardlinking is enabled for this task (Prowlarr torrents only).""" - if task.source != "prowlarr": - return False - - if not task.original_download_path: - return False - - is_audiobook = check_audiobook(task.content_type) - key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS" - - hardlink_enabled = config.get(key) - if hardlink_enabled is None: - hardlink_enabled = config.get("TORRENT_HARDLINK", False) - - return bool(hardlink_enabled) - - -def _should_extract_archives(task: DownloadTask) -> bool: - """Check if archives should be extracted (disabled when hardlinking).""" - return not _should_hardlink(task) - - -def _get_final_destination(task: DownloadTask) -> Path: - """Get final destination directory, with content-type routing support.""" - is_audiobook = check_audiobook(task.content_type) - - # For Anna's Archive (direct_download), check for content-type routing override - if task.source == "direct_download" and not is_audiobook: - override = get_aa_content_type_dir(task.content_type) - if override: - return override - - return get_destination(is_audiobook) - - -def _build_metadata_dict(task: DownloadTask) -> dict: - """Build metadata dictionary from task for template processing.""" - return { - "Author": task.author, - "Title": task.title, - "Subtitle": task.subtitle, - "Year": task.year, - "Series": task.series_name, - "SeriesPosition": task.series_position, - } - - -def _get_supported_formats(content_type: str = None) -> List[str]: - """Get current supported formats from config singleton based on content type.""" - if check_audiobook(content_type): - return _get_supported_audiobook_formats() - return _get_book_formats() - - -def _find_book_files_in_directory(directory: Path, content_type: str = None) -> Tuple[List[Path], List[Path]]: - """Find book files matching supported formats. Returns (matches, rejected).""" - book_files = [] - rejected_files = [] - supported_formats = _get_supported_formats(content_type) - supported_exts = {f".{fmt}" for fmt in supported_formats} - - is_audiobook = check_audiobook(content_type) - if is_audiobook: - trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'} - else: - trackable_exts = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'} - - for file_path in directory.rglob("*"): - if file_path.is_file(): - if file_path.suffix.lower() in supported_exts: - book_files.append(file_path) - elif file_path.suffix.lower() in trackable_exts: - rejected_files.append(file_path) - - return book_files, rejected_files - - -def process_directory( - directory: Path, - ingest_dir: Path, - task: DownloadTask, -) -> Tuple[List[Path], Optional[str]]: - """Process staged directory: find book files, extract archives, move to ingest.""" - try: - content_type = task.content_type - book_files, rejected_files = _find_book_files_in_directory(directory, content_type) - - # Find archives in directory (ZIP/RAR) - archive_files = [f for f in directory.rglob("*") if f.is_file() and is_archive(f)] - - if not book_files: - # No direct book files - check for archives to extract - if archive_files: - logger.info(f"No book files found, extracting {len(archive_files)} archive(s)") - all_final_paths = [] - all_errors = [] - - for archive in archive_files: - result = process_archive( - archive_path=archive, - temp_dir=directory, - ingest_dir=ingest_dir, - archive_id=f"{task.task_id}_{archive.stem}", - task=task, - ) - if result.success: - all_final_paths.extend(result.final_paths) - elif result.error: - all_errors.append(f"{archive.name}: {result.error}") - - # Clean up directory after processing archives - shutil.rmtree(directory, ignore_errors=True) - - if all_final_paths: - return all_final_paths, None - elif all_errors: - return [], "; ".join(all_errors) - else: - return [], "No book files found in archives" - - # No book files and no archives - shutil.rmtree(directory, ignore_errors=True) - - if rejected_files: - # Files were found but didn't match supported formats - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) - rejected_list = ", ".join(rejected_exts) - supported_formats = _get_supported_formats(content_type) - logger.warning( - f"Found {len(rejected_files)} file(s) but none match supported formats. " - f"Rejected formats: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}" - ) - return [], f"Found {len(rejected_files)} file(s) but format not supported ({rejected_list}). Enable in Settings > Formats." - - return [], "No book files found in download" - - # We have book files - use them directly, skip any archives - if archive_files: - logger.debug(f"Ignoring {len(archive_files)} archive(s) - already have {len(book_files)} book file(s)") - - logger.info(f"Found {len(book_files)} book file(s) in directory") - - if rejected_files: - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) - logger.debug(f"Also found {len(rejected_files)} file(s) with unsupported formats: {', '.join(rejected_exts)}") - - # Transfer each book file to destination - final_paths = [] - is_audiobook = check_audiobook(task.content_type) - organization_mode = _get_file_organization(is_audiobook) - use_hardlink = _should_hardlink(task) - is_torrent = _is_torrent_source(directory, task) - - for book_file in book_files: - # For multi-file downloads (book packs, series), always preserve original filenames - # since metadata title only applies to the searched book, not the whole pack. - # For single files, respect FILE_ORGANIZATION setting. - if len(book_files) == 1 and organization_mode != "none": - # Update task format from actual file if not already set - # (Prowlarr releases may not know the format until download completes) - if not task.format: - task.format = book_file.suffix.lower().lstrip('.') - - # Apply template to generate filename - template = _get_template(is_audiobook, "rename") - metadata = _build_metadata_dict(task) - extension = book_file.suffix.lstrip('.') or task.format or "" - - filename = parse_naming_template(template, metadata) - if filename and extension: - filename = f"{sanitize_filename(filename)}.{extension}" - else: - filename = book_file.name - else: - filename = book_file.name - - dest_path = ingest_dir / filename - final_path, op = _transfer_single_file(book_file, dest_path, use_hardlink, is_torrent) - final_paths.append(final_path) - logger.debug(f"{op.capitalize()} to destination: {final_path.name}") - - if not is_torrent: - shutil.rmtree(directory, ignore_errors=True) - - return final_paths, None - - except Exception as e: - logger.error(f"Error processing directory: {e}") - if not _is_torrent_source(directory, task): - shutil.rmtree(directory, ignore_errors=True) - return [], str(e) +# +# Post-download processing (staging, extraction, transfers, cleanup) lives in +# `shelfmark.download.postprocess`. # WebSocket manager (initialized by app.py) @@ -496,14 +257,22 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: try: # Check for cancellation before starting if cancel_flag.is_set(): - logger.info(f"Download cancelled before starting: {task_id}") + logger.info("Task %s: cancelled before starting", task_id) return None task = book_queue.get_task(task_id) if not task: - logger.error(f"Task not found in queue: {task_id}") + logger.error("Task not found in queue: %s", task_id) return None + title_label = task.title or "Unknown title" + logger.info( + "Task %s: starting download (%s) - %s", + task_id, + get_source_display_name(task.source), + title_label, + ) + def progress_callback(progress: float) -> None: update_download_progress(task_id, progress) @@ -530,24 +299,37 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: # Check cancellation before post-processing if cancel_flag.is_set(): - logger.info(f"Download cancelled before post-processing: {task_id}") - if not _is_torrent_source(temp_file, task): - if temp_file.is_dir(): - shutil.rmtree(temp_file, ignore_errors=True) - else: - temp_file.unlink(missing_ok=True) + logger.info("Task %s: cancelled before post-processing", task_id) + if not is_torrent_source(temp_file, task): + safe_cleanup_path(temp_file, task) return None - # Post-processing: archive extraction or direct move to ingest - return _post_process_download( - temp_file, task, cancel_flag, status_callback - ) + logger.info("Task %s: download finished; starting post-processing", task_id) + logger.debug("Task %s: post-processing input path: %s", task_id, temp_file) + + # Post-processing: output routing + file processing pipeline + result = post_process_download(temp_file, task, cancel_flag, status_callback) + + if cancel_flag.is_set(): + logger.info("Task %s: post-processing cancelled", task_id) + elif result: + logger.info("Task %s: post-processing complete", task_id) + logger.debug("Task %s: post-processing result: %s", task_id, result) + else: + logger.warning("Task %s: post-processing failed", task_id) + + try: + handler.post_process_cleanup(task, success=bool(result)) + except Exception as e: + logger.warning("Post-processing cleanup hook failed for %s: %s", task_id, e) + + return result except Exception as e: if cancel_flag.is_set(): - logger.info(f"Download cancelled during error handling: {task_id}") + logger.info("Task %s: cancelled during error handling", task_id) else: - logger.error_trace(f"Error downloading: {e}") + logger.error_trace("Task %s: error downloading: %s", task_id, e) # Update task status so user sees the failure task = book_queue.get_task(task_id) if task: @@ -559,414 +341,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: "Destination misconfigured. Go to Settings → Downloads to update." ) else: - book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}") + if isinstance(e, PermissionError): + book_queue.update_status_message(task_id, f"Permission denied: {e}") + else: + book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}") return None -def _process_organize_mode( - temp_file: Path, - task: DownloadTask, - status_callback, -) -> Optional[str]: - """Organize files into library folders using template. Supports hardlinking.""" - is_audiobook = check_audiobook(task.content_type) - - # Get destination and template - destination = _get_final_destination(task) - template = _get_template(is_audiobook, "organize") - - # Validate destination path - if not destination.is_absolute(): - logger.warning(f"Destination must be absolute: {destination}, falling back to flat mode") - status_callback("resolving", f"Destination must be absolute: {destination}") - return None - - if not destination.exists(): - try: - destination.mkdir(parents=True, exist_ok=True) - except (OSError, PermissionError) as e: - logger.warning(f"Cannot create destination: {e}") - status_callback("resolving", f"Cannot create destination: {e}") - return None - - if not os.access(destination, os.W_OK): - logger.warning(f"Destination not writable: {destination}") - status_callback("resolving", f"Destination not writable: {destination}") - return None - - # Determine if we should use hardlinking - use_hardlink = False - source = temp_file - - if _should_hardlink(task): - hardlink_source = Path(task.original_download_path) - if hardlink_source.exists() and same_filesystem(hardlink_source, destination): - use_hardlink = True - source = hardlink_source - elif hardlink_source.exists(): - logger.warning( - f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. " - "Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination." - ) - status_callback("resolving", "Cannot hardlink (different filesystems), using copy") - - # Build metadata dict for template - metadata = _build_metadata_dict(task) - - try: - status_callback("resolving", "Creating hardlinks" if use_hardlink else "Organizing files") - - if source.is_dir(): - return _transfer_directory_to_library( - source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink - ) - else: - return _transfer_file_to_library( - source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink - ) - except PermissionError as e: - logger.error(f"Permission denied: {e}") - status_callback("error", f"Permission denied: {e}") - return None - except Exception as e: - logger.error_trace(f"Organization failed: {e}") - status_callback("error", f"Organization failed: {e}") - return None - - -def _is_torrent_source(source_path: Path, task: DownloadTask) -> bool: - """Check if source is the torrent client path (needs copy to preserve seeding).""" - if not task.original_download_path: - return False - try: - return source_path.resolve() == Path(task.original_download_path).resolve() - except (OSError, ValueError): - return False - - -def _stage_torrent_path(source: Path) -> Path: - """Copy torrent source to staging directory to preserve seeding.""" - staging_dir = get_staging_dir() - staged_path = staging_dir / source.name - counter = 1 - - if source.is_dir(): - while staged_path.exists(): - staged_path = staging_dir / f"{source.name}_{counter}" - counter += 1 - shutil.copytree(str(source), str(staged_path)) - else: - while staged_path.exists(): - staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}" - counter += 1 - shutil.copy2(str(source), str(staged_path)) - - logger.debug(f"Staged torrent {'directory' if source.is_dir() else 'file'}: {staged_path.name}") - return staged_path - - -# Import atomic file operations from shared module -# Re-exported here for backwards compatibility with existing tests/imports -from shelfmark.download.fs import ( - atomic_hardlink as _atomic_hardlink, - atomic_copy as _atomic_copy, - atomic_move as _atomic_move, -) - - -def _cleanup_staged_files(temp_file: Path, source_dir: Optional[Path] = None) -> None: - """Remove staged files. Optionally removes source_dir if empty.""" - try: - if temp_file.is_dir(): - shutil.rmtree(temp_file) - elif temp_file.exists(): - temp_file.unlink() - except (OSError, PermissionError) as e: - logger.debug(f"Cleanup failed for {temp_file}: {e}") - - if source_dir and source_dir.is_dir(): - try: - source_dir.rmdir() - except OSError: - pass # Directory not empty or permission issue - - -def _transfer_single_file( - source_path: Path, - dest_path: Path, - use_hardlink: bool, - is_torrent: bool, -) -> Tuple[Path, str]: - """Transfer a file via hardlink, copy, or move. Returns (final_path, operation_name).""" - if use_hardlink: - return _atomic_hardlink(source_path, dest_path), "hardlink" - if is_torrent: - return _atomic_copy(source_path, dest_path), "copy" - return _atomic_move(source_path, dest_path), "move" - - -def _transfer_file_to_library( - source_path: Path, - library_base: str, - template: str, - metadata: dict, - task: DownloadTask, - temp_file: Optional[Path], - status_callback, - use_hardlink: bool, -) -> Optional[str]: - """Transfer a single file to the library with template-based naming.""" - extension = source_path.suffix.lstrip('.') or task.format - dest_path = build_library_path(library_base, template, metadata, extension) - - dest_path.parent.mkdir(parents=True, exist_ok=True) - - is_torrent = _is_torrent_source(source_path, task) - final_path, op = _transfer_single_file(source_path, dest_path, use_hardlink, is_torrent) - logger.info(f"Library {op}: {final_path}") - - if use_hardlink: - _cleanup_staged_files(temp_file) - - status_callback("complete", "Complete") - return str(final_path) - - -def _transfer_directory_to_library( - source_dir: Path, - library_base: str, - template: str, - metadata: dict, - task: DownloadTask, - temp_file: Optional[Path], - status_callback, - use_hardlink: bool, -) -> Optional[str]: - """Transfer all files from a directory to the library with template-based naming.""" - content_type = task.content_type.lower() if task.content_type else None - supported_formats = _get_supported_formats(content_type) - - source_files = [ - f for f in source_dir.rglob("*") - if f.is_file() and f.suffix.lower().lstrip('.') in supported_formats - ] - - if not source_files: - logger.warning(f"No supported files in {source_dir.name}") - status_callback("error", "No supported file formats found") - if temp_file: - _cleanup_staged_files(temp_file) - return None - - base_library_path = build_library_path(library_base, template, metadata, extension=None) - base_library_path.parent.mkdir(parents=True, exist_ok=True) - - # Check if this is a torrent source that needs copy instead of move - is_torrent = _is_torrent_source(source_dir, task) - transferred_paths = [] - - if len(source_files) == 1: - # Single file - no part numbering needed - source_file = source_files[0] - ext = source_file.suffix.lstrip('.') - dest_path = base_library_path.with_suffix(f'.{ext}') - - final_path, op = _transfer_single_file(source_file, dest_path, use_hardlink, is_torrent) - logger.debug(f"Library {op}: {source_file.name} -> {final_path}") - transferred_paths.append(final_path) - else: - # Multi-file: natural sort then sequential numbering - zero_pad_width = max(len(str(len(source_files))), 2) - files_with_parts = assign_part_numbers(source_files, zero_pad_width) - - for source_file, part_number in files_with_parts: - ext = source_file.suffix.lstrip('.') - - file_metadata = {**metadata, "PartNumber": part_number} - file_path = build_library_path(library_base, template, file_metadata, extension=ext) - file_path.parent.mkdir(parents=True, exist_ok=True) - - final_path, op = _transfer_single_file(source_file, file_path, use_hardlink, is_torrent) - logger.debug(f"Library {op}: {source_file.name} -> {final_path}") - transferred_paths.append(final_path) - - # Get operation name for summary log - if use_hardlink: - operation = "hardlinks" - elif is_torrent: - operation = "copies" - else: - operation = "files" - logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}") - - # Cleanup staging (not torrent source - that stays for seeding) - if use_hardlink: - _cleanup_staged_files(temp_file) - elif not is_torrent: - _cleanup_staged_files(temp_file, source_dir) - - message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete" - status_callback("complete", message) - - return str(transferred_paths[0]) - - -def _post_process_download( - temp_file: Path, - task: DownloadTask, - cancel_flag: Event, - status_callback, -) -> Optional[str]: - """Post-process download: extract archives, apply naming template, move to destination.""" - is_audiobook = check_audiobook(task.content_type) - - # Validate search_mode - if task.search_mode is None: - logger.warning(f"Task {task.task_id} has no search_mode set, defaulting to Direct mode behavior") - elif task.search_mode not in (SearchMode.DIRECT, SearchMode.UNIVERSAL): - logger.warning(f"Task {task.task_id} has invalid search_mode '{task.search_mode}', defaulting to Direct mode behavior") - - # Get file organization mode and destination - organization_mode = _get_file_organization(is_audiobook) - destination = _get_final_destination(task) - - logger.debug(f"File organization: mode={organization_mode}, destination={destination}") - - # "Organize" mode with folders uses specialized handler - if organization_mode == "organize": - result = _process_organize_mode(temp_file, task, status_callback) - if result is not None: - return result - # If organize mode fails, fall through to flat mode - logger.warning( - f"Organize mode failed for '{task.title}', falling back to flat destination. " - "Check destination folder permissions and ensure the path is writable." - ) - status_callback("resolving", "Organization failed, using flat destination") - - # Ensure destination exists - os.makedirs(destination, exist_ok=True) - - # For torrents with hardlinking disabled, stage first to preserve seeding - # (Torrent handler returns original path, not staged copy) - if _is_torrent_source(temp_file, task) and not _should_hardlink(task): - status_callback("resolving", "Staging torrent files") - temp_file = _stage_torrent_path(temp_file) - - # Handle archive extraction (RAR/ZIP) - only if not hardlinking - if is_archive(temp_file) and _should_extract_archives(task): - logger.info(f"Archive detected, extracting: {temp_file.name}") - status_callback("resolving", "Extracting archive") - - result = process_archive( - archive_path=temp_file, - temp_dir=TMP_DIR, - ingest_dir=destination, - archive_id=task.task_id, - task=task, - ) - - if result.success: - status_callback("complete", result.message) - return str(result.final_paths[0]) - else: - status_callback("error", result.error) - return None - - # Handle directory (multi-file torrent/usenet downloads) - if temp_file.is_dir(): - logger.info(f"Directory detected, processing: {temp_file.name}") - status_callback("resolving", "Processing download folder") - - final_paths, error = process_directory( - directory=temp_file, - ingest_dir=destination, - task=task, - ) - - if error: - status_callback("error", error) - return None - - if not final_paths: - status_callback("error", "No book files found") - return None - - message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)" - status_callback("complete", message) - return str(final_paths[0]) - - # Non-archive: run custom script if configured, then move to destination - if config.CUSTOM_SCRIPT: - logger.info(f"Running custom script: {config.CUSTOM_SCRIPT}") - try: - result = subprocess.run( - [config.CUSTOM_SCRIPT, str(temp_file)], - check=True, - timeout=300, # 5 minute timeout - capture_output=True, - text=True, - ) - if result.stdout: - logger.debug(f"Custom script stdout: {result.stdout.strip()}") - except FileNotFoundError: - logger.error(f"Custom script not found: {config.CUSTOM_SCRIPT}") - status_callback("error", f"Custom script not found: {config.CUSTOM_SCRIPT}") - return None - except PermissionError: - logger.error(f"Custom script not executable: {config.CUSTOM_SCRIPT}") - status_callback("error", f"Custom script not executable: {config.CUSTOM_SCRIPT}") - return None - except subprocess.TimeoutExpired: - logger.error(f"Custom script timed out after 300s: {config.CUSTOM_SCRIPT}") - status_callback("error", "Custom script timed out") - return None - except subprocess.CalledProcessError as e: - stderr = e.stderr.strip() if e.stderr else "No error output" - logger.error(f"Custom script failed (exit code {e.returncode}): {stderr}") - status_callback("error", f"Custom script failed: {stderr[:100]}") - return None - - use_hardlink = _should_hardlink(task) - is_torrent = _is_torrent_source(temp_file, task) - - if cancel_flag.is_set(): - logger.info(f"Download cancelled before final transfer: {task.task_id}") - if not is_torrent: - temp_file.unlink(missing_ok=True) - return None - - # Determine filename based on organization mode - if organization_mode == "none": - # Keep original filename - filename = temp_file.name - else: - # "rename" mode - apply template to filename - template = _get_template(is_audiobook, "rename") - metadata = _build_metadata_dict(task) - extension = temp_file.suffix.lstrip('.') or task.format or "" - - # Parse template to generate filename - filename = parse_naming_template(template, metadata) - if filename and extension: - filename = f"{sanitize_filename(filename)}.{extension}" - elif not filename: - # Template produced empty result, fall back to original - filename = temp_file.name - - dest_path = destination / filename - - try: - final_path, op = _transfer_single_file(temp_file, dest_path, use_hardlink, is_torrent) - logger.info(f"Download completed ({op}): {final_path.name}") - except Exception as e: - logger.error(f"Failed to transfer file to destination: {e}") - status_callback("error", f"Failed to transfer file: {e}") - return None - - status_callback("complete", "Complete") - - return str(final_path) def update_download_progress(book_id: str, progress: float) -> None: """Update download progress with throttled WebSocket broadcasts.""" diff --git a/shelfmark/download/outputs/__init__.py b/shelfmark/download/outputs/__init__.py new file mode 100644 index 0000000..55188ec --- /dev/null +++ b/shelfmark/download/outputs/__init__.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Callable, Optional + +from shelfmark.core.models import DownloadTask + +StatusCallback = Callable[[str, Optional[str]], None] +OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback], Optional[str]] + + +@dataclass(frozen=True) +class OutputRegistration: + mode: str + supports_task: Callable[[DownloadTask], bool] + handler: OutputHandler + priority: int = 0 + + +_OUTPUT_REGISTRY: list[OutputRegistration] = [] +_OUTPUTS_LOADED = False + + +def register_output( + mode: str, + supports_task: Callable[[DownloadTask], bool], + priority: int = 0, +) -> Callable[[OutputHandler], OutputHandler]: + def decorator(handler: OutputHandler) -> OutputHandler: + _OUTPUT_REGISTRY.append( + OutputRegistration( + mode=mode, + supports_task=supports_task, + handler=handler, + priority=priority, + ) + ) + _OUTPUT_REGISTRY.sort(key=lambda entry: entry.priority, reverse=True) + return handler + + return decorator + + +def load_output_handlers() -> None: + global _OUTPUTS_LOADED + if _OUTPUTS_LOADED: + return + + from . import booklore # noqa: F401 + from . import folder # noqa: F401 + + _OUTPUTS_LOADED = True + + +def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]: + load_output_handlers() + for entry in _OUTPUT_REGISTRY: + if entry.supports_task(task): + return entry + return None diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py new file mode 100644 index 0000000..43b8653 --- /dev/null +++ b/shelfmark/download/outputs/booklore.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Any, Dict, List, Mapping, Optional + +import requests + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.outputs import register_output +from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir + +logger = setup_logger(__name__) + +BOOKLORE_OUTPUT_MODE = "booklore" +BOOKLORE_SUPPORTED_EXTENSIONS = {".cb7", ".cbr", ".cbz", ".epub", ".fb2", ".pdf"} +BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join( + ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS) +) + + +class BookloreError(Exception): + """Raised when Booklore integration fails.""" + + +@dataclass(frozen=True) +class BookloreConfig: + base_url: str + username: str + password: str + library_id: int + path_id: int + verify_tls: bool = True + refresh_after_upload: bool = False + + +def _parse_int(value: Any, label: str) -> int: + if value is None or value == "": + raise BookloreError(f"{label} is required") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise BookloreError(f"{label} must be a number") from exc + + +def build_booklore_config(values: Mapping[str, Any]) -> BookloreConfig: + base_url = str(values.get("BOOKLORE_HOST", "")).strip() + username = str(values.get("BOOKLORE_USERNAME", "")).strip() + password = values.get("BOOKLORE_PASSWORD", "") or "" + + if not base_url: + raise BookloreError("Booklore URL is required") + if not username: + raise BookloreError("Booklore username is required") + if not password: + raise BookloreError("Booklore password is required") + + library_id = _parse_int(values.get("BOOKLORE_LIBRARY_ID"), "Booklore library ID") + path_id = _parse_int(values.get("BOOKLORE_PATH_ID"), "Booklore path ID") + + return BookloreConfig( + base_url=base_url.rstrip("/"), + username=username, + password=password, + library_id=library_id, + path_id=path_id, + verify_tls=True, + refresh_after_upload=True, # Always refresh library after upload + ) + + +def booklore_login(booklore_config: BookloreConfig) -> str: + url = f"{booklore_config.base_url}/api/v1/auth/login" + payload = {"username": booklore_config.username, "password": booklore_config.password} + + try: + response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls) + except requests.exceptions.ConnectionError as exc: + raise BookloreError("Could not connect to Booklore") from exc + except requests.exceptions.Timeout as exc: + raise BookloreError("Booklore connection timed out") from exc + except requests.exceptions.RequestException as exc: + raise BookloreError(f"Booklore login failed: {exc}") from exc + + if response.status_code in {401, 403}: + raise BookloreError("Booklore authentication failed") + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + raise BookloreError(f"Booklore login failed ({response.status_code})") from exc + + try: + data = response.json() + except ValueError as exc: + raise BookloreError("Invalid Booklore login response") from exc + + token = data.get("accessToken") + if not token: + raise BookloreError("Booklore did not return an access token") + + return token + + +def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list[dict[str, Any]]: + url = f"{booklore_config.base_url}/api/v1/libraries" + headers = {"Authorization": f"Bearer {token}"} + + try: + response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + raise BookloreError(f"Failed to fetch Booklore libraries: {exc}") from exc + + try: + return response.json() + except ValueError as exc: + raise BookloreError("Invalid Booklore libraries response") from exc + + +def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None: + url = f"{booklore_config.base_url}/api/v1/files/upload" + headers = {"Authorization": f"Bearer {token}"} + params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id} + + response = None + + try: + with file_path.open("rb") as handle: + response = requests.post( + url, + headers=headers, + params=params, + files={"file": (file_path.name, handle)}, + timeout=60, + verify=booklore_config.verify_tls, + ) + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + message = response.text.strip() if response is not None else "" + if message: + message = f": {message[:200]}" + status_code = response.status_code if response is not None else "unknown" + raise BookloreError(f"Booklore upload failed ({status_code}){message}") from exc + except requests.exceptions.ConnectionError as exc: + raise BookloreError("Could not connect to Booklore") from exc + except requests.exceptions.Timeout as exc: + raise BookloreError("Booklore upload timed out") from exc + except requests.exceptions.RequestException as exc: + raise BookloreError(f"Booklore upload failed: {exc}") from exc + + +def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None: + url = f"{booklore_config.base_url}/api/v1/libraries/{booklore_config.library_id}/refresh" + headers = {"Authorization": f"Bearer {token}"} + + try: + response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + raise BookloreError(f"Booklore refresh failed: {exc}") from exc + + +def _supports_booklore(task: DownloadTask) -> bool: + if check_audiobook(task.content_type): + return False + return core_config.config.get("BOOKS_OUTPUT_MODE", "folder") == BOOKLORE_OUTPUT_MODE + + +def _get_booklore_settings() -> Dict[str, Any]: + return { + "BOOKLORE_HOST": core_config.config.get("BOOKLORE_HOST", ""), + "BOOKLORE_USERNAME": core_config.config.get("BOOKLORE_USERNAME", ""), + "BOOKLORE_PASSWORD": core_config.config.get("BOOKLORE_PASSWORD", ""), + "BOOKLORE_LIBRARY_ID": core_config.config.get("BOOKLORE_LIBRARY_ID"), + "BOOKLORE_PATH_ID": core_config.config.get("BOOKLORE_PATH_ID"), + } + + +def _booklore_format_error(rejected_files: List[Path]) -> str: + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + rejected_list = ", ".join(rejected_exts) + return ( + f"Booklore does not support {rejected_list}. " + f"Supported formats: {BOOKLORE_SUPPORTED_FORMATS_LABEL}" + ) + + +def _post_process_booklore( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + from shelfmark.download.postprocess.pipeline import ( + OutputPlan, + cleanup_output_staging, + is_managed_workspace_path, + prepare_output_files, + ) + + if cancel_flag.is_set(): + logger.info("Task %s: cancelled before Booklore upload", task.task_id) + return None + + try: + booklore_config = build_booklore_config(_get_booklore_settings()) + except BookloreError as e: + logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e) + status_callback("error", str(e)) + return None + + status_callback("resolving", "Preparing Booklore upload") + + output_plan = OutputPlan( + mode=BOOKLORE_OUTPUT_MODE, + stage_action=STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE, + staging_dir=build_staging_dir("booklore", task.task_id), + allow_archive_extraction=True, + ) + + prepared = prepare_output_files( + temp_file, + task, + BOOKLORE_OUTPUT_MODE, + status_callback, + output_plan=output_plan, + ) + if not prepared: + return None + + logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files)) + + try: + unsupported_files = [ + file_path + for file_path in prepared.files + if file_path.suffix.lower() not in BOOKLORE_SUPPORTED_EXTENSIONS + ] + if unsupported_files: + error_message = _booklore_format_error(unsupported_files) + logger.warning("Task %s: %s", task.task_id, error_message) + status_callback("error", error_message) + return None + + token = booklore_login(booklore_config) + logger.info("Task %s: uploading %d file(s) to Booklore", task.task_id, len(prepared.files)) + + for index, file_path in enumerate(prepared.files, start=1): + if cancel_flag.is_set(): + logger.info("Task %s: cancelled during Booklore upload", task.task_id) + return None + status_callback("resolving", f"Uploading to Booklore ({index}/{len(prepared.files)})") + booklore_upload_file(booklore_config, token, file_path) + + if booklore_config.refresh_after_upload: + try: + booklore_refresh_library(booklore_config, token) + except BookloreError as e: + logger.warning("Task %s: Booklore refresh failed: %s", task.task_id, e) + + logger.info("Task %s: uploaded %d file(s) to Booklore", task.task_id, len(prepared.files)) + + message = "Uploaded to Booklore" + if len(prepared.files) > 1: + message = f"Uploaded to Booklore ({len(prepared.files)} files)" + status_callback("complete", message) + return f"booklore://{task.task_id}" + + except BookloreError as e: + logger.warning("Task %s: Booklore upload failed: %s", task.task_id, e) + status_callback("error", str(e)) + return None + except Exception as e: + logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e) + status_callback("error", f"Booklore upload failed: {e}") + return None + finally: + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + + +@register_output(BOOKLORE_OUTPUT_MODE, supports_task=_supports_booklore, priority=10) +def process_booklore_output( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + return _post_process_booklore(temp_file, task, cancel_flag, status_callback) diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py new file mode 100644 index 0000000..1506336 --- /dev/null +++ b/shelfmark/download/outputs/folder.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Any, Optional, List + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.archive import is_archive +from shelfmark.download.outputs import register_output +from shelfmark.download.staging import StageAction, STAGE_NONE + +logger = setup_logger(__name__) + +FOLDER_OUTPUT_MODE = "folder" + + +@dataclass(frozen=True) +class _ProcessingPlan: + destination: Path + organization_mode: str + use_hardlink: bool + allow_archive_extraction: bool + stage_action: StageAction + staging_dir: Path + hardlink_source: Optional[Path] + output_mode: str = FOLDER_OUTPUT_MODE + + +def _supports_folder_output(task: DownloadTask) -> bool: + if check_audiobook(task.content_type): + return True + return core_config.config.get("BOOKS_OUTPUT_MODE", FOLDER_OUTPUT_MODE) == FOLDER_OUTPUT_MODE + + +def _build_processing_plan( + temp_file: Path, + task: DownloadTask, + status_callback, +) -> Optional[_ProcessingPlan]: + from shelfmark.download.postprocess.pipeline import ( + build_output_plan, + get_final_destination, + validate_destination, + ) + from shelfmark.download.postprocess.policy import get_file_organization + + is_audiobook = check_audiobook(task.content_type) + organization_mode = get_file_organization(is_audiobook) + destination = get_final_destination(task) + + if not validate_destination(destination, status_callback): + return None + + output_plan = build_output_plan( + temp_file, + task, + output_mode=FOLDER_OUTPUT_MODE, + destination=destination, + status_callback=status_callback, + ) + if not output_plan.transfer_plan: + return None + + transfer_plan = output_plan.transfer_plan + hardlink_source = transfer_plan.source_path if transfer_plan.use_hardlink else None + + return _ProcessingPlan( + destination=destination, + organization_mode=organization_mode, + use_hardlink=transfer_plan.use_hardlink, + allow_archive_extraction=transfer_plan.allow_archive_extraction, + stage_action=output_plan.stage_action, + staging_dir=output_plan.staging_dir, + hardlink_source=hardlink_source, + ) + + +@register_output(FOLDER_OUTPUT_MODE, supports_task=_supports_folder_output, priority=0) +def process_folder_output( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + """Post-process download to the configured folder destination.""" + from shelfmark.download.postprocess.pipeline import ( + cleanup_output_staging, + is_torrent_source, + log_plan_steps, + prepare_output_files, + record_step, + safe_cleanup_path, + transfer_book_files, + ) + + plan = _build_processing_plan(temp_file, task, status_callback) + if not plan: + return None + + logger.debug( + "Processing plan for task %s: mode=%s destination=%s hardlink=%s stage_action=%s extract_archives=%s", + task.task_id, + plan.organization_mode, + plan.destination, + plan.use_hardlink, + plan.stage_action, + plan.allow_archive_extraction, + ) + + prepared = prepare_output_files( + temp_file, + task, + output_mode=plan.output_mode, + status_callback=status_callback, + destination=plan.destination, + ) + if not prepared: + return None + + steps: List[Any] = [] + if prepared.output_plan.stage_action != STAGE_NONE: + step_name = f"stage_{prepared.output_plan.stage_action}" + record_step(steps, step_name, source=str(temp_file), dest=str(prepared.output_plan.staging_dir)) + + # Run custom script only for non-archive single files (matches legacy behavior) + if core_config.config.CUSTOM_SCRIPT and prepared.working_path.is_file() and not is_archive(prepared.working_path): + record_step(steps, "custom_script", script=str(core_config.config.CUSTOM_SCRIPT)) + log_plan_steps(task.task_id, steps) + logger.info( + "Task %s: running custom script %s on %s", + task.task_id, + core_config.config.CUSTOM_SCRIPT, + prepared.working_path, + ) + try: + result = subprocess.run( + [core_config.config.CUSTOM_SCRIPT, str(prepared.working_path)], + check=True, + timeout=300, # 5 minute timeout + capture_output=True, + text=True, + ) + if result.stdout: + logger.debug("Task %s: custom script stdout: %s", task.task_id, result.stdout.strip()) + except FileNotFoundError: + logger.error("Task %s: custom script not found: %s", task.task_id, core_config.config.CUSTOM_SCRIPT) + status_callback("error", f"Custom script not found: {core_config.config.CUSTOM_SCRIPT}") + return None + except PermissionError: + logger.error( + "Task %s: custom script not executable: %s", + task.task_id, + core_config.config.CUSTOM_SCRIPT, + ) + status_callback("error", f"Custom script not executable: {core_config.config.CUSTOM_SCRIPT}") + return None + except subprocess.TimeoutExpired: + logger.error( + "Task %s: custom script timed out after 300s: %s", + task.task_id, + core_config.config.CUSTOM_SCRIPT, + ) + status_callback("error", "Custom script timed out") + return None + except subprocess.CalledProcessError as e: + stderr = e.stderr.strip() if e.stderr else "No error output" + logger.error( + "Task %s: custom script failed (exit code %s): %s", + task.task_id, + e.returncode, + stderr, + ) + status_callback("error", f"Custom script failed: {stderr[:100]}") + return None + + # If we staged a copy into TMP_DIR (e.g. for custom script), transfer from the staged + # path and disable hardlinking for this transfer. + use_hardlink = plan.use_hardlink and prepared.output_plan.stage_action == STAGE_NONE + source_path = plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path + is_torrent = is_torrent_source(source_path, task) + + usenet_action = core_config.config.get("PROWLARR_USENET_ACTION", "move") + is_usenet = task.source == "prowlarr" and not task.original_download_path + + # For external usenet downloads, always copy from the client path. + # "Move" is implemented as a client-side cleanup after import. + preserve_source = is_usenet + + copy_for_label = is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE + + if cancel_flag.is_set(): + logger.info("Task %s: cancelled before final transfer", task.task_id) + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + return None + + if use_hardlink: + op_label = "Hardlinking" + elif is_usenet and usenet_action == "move" and prepared.output_plan.stage_action == STAGE_NONE: + # Presented as a move, but implemented as copy + client cleanup. + op_label = "Moving" + elif copy_for_label: + op_label = "Copying" + else: + op_label = "Moving" + + status_callback("resolving", f"{op_label} file") + record_step( + steps, + "transfer", + op=op_label.lower(), + source=str(source_path), + dest=str(plan.destination), + hardlink=use_hardlink, + torrent=copy_for_label, + ) + if prepared.output_plan.stage_action != STAGE_NONE: + record_step(steps, "cleanup_staging", path=str(prepared.working_path)) + log_plan_steps(task.task_id, steps) + + final_paths, error = transfer_book_files( + prepared.files, + destination=plan.destination, + task=task, + use_hardlink=use_hardlink, + is_torrent=is_torrent, + preserve_source=preserve_source, + organization_mode=plan.organization_mode, + ) + + if error: + logger.warning("Task %s: transfer failed: %s", task.task_id, error) + status_callback("error", error) + return None + + logger.info( + "Task %s: transferred %d file(s) to %s (%s)", + task.task_id, + len(final_paths), + plan.destination, + op_label.lower(), + ) + + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + + message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)" + status_callback("complete", message) + + return str(final_paths[0]) diff --git a/shelfmark/download/permissions_debug.py b/shelfmark/download/permissions_debug.py new file mode 100644 index 0000000..6e5ecca --- /dev/null +++ b/shelfmark/download/permissions_debug.py @@ -0,0 +1,125 @@ +"""Permission/ownership diagnostics for filesystem operations. + +This module centralizes best-effort debug logging used by download post-processing +and atomic filesystem operations. + +It is intentionally defensive: failures collecting context should never mask the +original error. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from shelfmark.core.logger import setup_logger + +logger = setup_logger(__name__) + + +def _format_uid(uid: int) -> str: + try: + import pwd + + return pwd.getpwuid(uid).pw_name + except Exception: + return str(uid) + + +def _format_gid(gid: int) -> str: + try: + import grp + + return grp.getgrgid(gid).gr_name + except Exception: + return str(gid) + + +def log_path_permission_context(label: str, path: Path) -> None: + """Log useful permission/ownership context for a path. + + Only call this from failure paths. + """ + + try: + euid = os.geteuid() if hasattr(os, "geteuid") else None + egid = os.getegid() if hasattr(os, "getegid") else None + groups = os.getgroups() if hasattr(os, "getgroups") else [] + + if euid is not None and egid is not None: + logger.debug( + "Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s", + label, + _format_uid(euid), + euid, + _format_gid(egid), + egid, + [f"{_format_gid(g)}({g})" for g in groups], + ) + + for probe in [path, path.parent]: + try: + resolved = probe.resolve() + except Exception: + resolved = probe + + try: + st = probe.stat() + logger.debug( + "Path permissions (%s): path=%s resolved=%s mode=%s owner=%s(%d) group=%s(%d) dir=%s symlink=%s", + label, + probe, + resolved, + oct(st.st_mode & 0o777), + _format_uid(st.st_uid), + st.st_uid, + _format_gid(st.st_gid), + st.st_gid, + probe.is_dir(), + probe.is_symlink(), + ) + except Exception as stat_error: + logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error) + except Exception as context_error: + logger.debug("Permission context (%s): failed to collect: %s", label, context_error) + + +def log_transfer_permission_context(label: str, source: Path, dest: Path, error: Exception) -> None: + """Log useful permission/ownership context when a file transfer fails.""" + + try: + euid = os.geteuid() if hasattr(os, "geteuid") else None + egid = os.getegid() if hasattr(os, "getegid") else None + groups = os.getgroups() if hasattr(os, "getgroups") else [] + + if euid is not None and egid is not None: + logger.debug( + "Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s error=%s", + label, + _format_uid(euid), + euid, + _format_gid(egid), + egid, + [f"{_format_gid(g)}({g})" for g in groups], + error, + ) + + for probe in [source, dest, dest.parent]: + try: + st = probe.stat() + logger.debug( + "Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s", + label, + probe, + oct(st.st_mode & 0o777), + _format_uid(st.st_uid), + st.st_uid, + _format_gid(st.st_gid), + st.st_gid, + probe.exists(), + probe.is_dir(), + ) + except Exception as stat_error: + logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error) + except Exception as context_error: + logger.debug("Permission context (%s): failed to collect: %s", label, context_error) diff --git a/shelfmark/download/postprocess/__init__.py b/shelfmark/download/postprocess/__init__.py new file mode 100644 index 0000000..03a3eae --- /dev/null +++ b/shelfmark/download/postprocess/__init__.py @@ -0,0 +1,11 @@ +"""Post-download processing pipeline. + +This package contains the post-download processing pipeline (staging, scanning, +archive extraction, transfers, and safe cleanup) and the router that selects an +output handler. + +Output handlers live in `shelfmark.download.outputs` and should depend on +`pipeline` (not `router`) to avoid circular imports. +""" + +from .router import post_process_download diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py new file mode 100644 index 0000000..e08f6df --- /dev/null +++ b/shelfmark/download/postprocess/destination.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import uuid +from pathlib import Path + +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import ( + get_aa_content_type_dir, + get_destination, + is_audiobook as check_audiobook, +) +from shelfmark.download.permissions_debug import log_path_permission_context + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def validate_destination(destination: Path, status_callback) -> bool: + """Validate destination path is absolute, exists, and writable.""" + + if not destination.is_absolute(): + logger.warning(f"Destination must be absolute: {destination}") + status_callback("error", f"Destination must be absolute: {destination}") + return False + + if destination.exists() and not destination.is_dir(): + logger.warning(f"Destination is not a directory: {destination}") + status_callback("error", f"Destination is not a directory: {destination}") + return False + + if not destination.exists(): + try: + destination.mkdir(parents=True, exist_ok=True) + except (OSError, PermissionError) as exc: + log_path_permission_context("destination_create", destination) + logger.warning(f"Cannot create destination: {destination} ({exc})") + status_callback("error", f"Cannot create destination: {destination} ({exc})") + return False + + test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp" + + try: + test_content = ( + f"This file was created to verify if '{destination}' is writable. " + "It should've been automatically deleted. Feel free to delete it.\n" + ) + test_path.write_text(test_content) + test_path.unlink(missing_ok=True) + except Exception as exc: + logger.debug("Destination write probe path: %s", test_path) + log_path_permission_context("destination_write_probe", destination) + logger.warning(f"Destination not writable: {destination} ({exc})") + status_callback("error", f"Destination not writable: {destination} ({exc})") + return False + + return True + + +def get_final_destination(task: DownloadTask) -> Path: + """Get final destination directory, with content-type routing support.""" + + is_audiobook = check_audiobook(task.content_type) + + if task.source == "direct_download" and not is_audiobook: + override = get_aa_content_type_dir(task.content_type) + if override: + return override + + return get_destination(is_audiobook) diff --git a/shelfmark/download/postprocess/pipeline.py b/shelfmark/download/postprocess/pipeline.py new file mode 100644 index 0000000..a4c17bc --- /dev/null +++ b/shelfmark/download/postprocess/pipeline.py @@ -0,0 +1,76 @@ +"""Post-download processing pipeline. + +This module is the public API surface for post-download processing. + +Implementation lives in submodules in this package: + +- `types`: dataclasses used across the pipeline +- `workspace`: managed workspace + cleanup rules +- `scan`: directory scanning + archive extraction +- `transfer`: hardlink/copy/move + naming/organization +- `prepare`: staging plan + prepared file selection +- `steps`: lightweight plan logging helpers + +Keeping this file as a facade avoids churn in call sites while letting the +implementation stay modular. +""" + +from __future__ import annotations + +from .destination import get_final_destination, validate_destination +from .prepare import build_output_plan, prepare_output_files +from .scan import ( + collect_directory_files, + collect_staged_files, + extract_archive_files, + get_supported_formats, + scan_directory_tree, +) +from .steps import log_plan_steps, record_step +from .transfer import ( + build_metadata_dict, + is_torrent_source, + process_directory, + resolve_hardlink_source, + should_hardlink, + transfer_book_files, + transfer_directory_to_library, + transfer_file_to_library, +) +from .types import OutputPlan, PlanStep, PreparedFiles, TransferPlan +from .workspace import ( + cleanup_output_staging, + is_managed_workspace_path, + is_within_tmp_dir, + safe_cleanup_path, +) + +__all__ = [ + "OutputPlan", + "PlanStep", + "PreparedFiles", + "TransferPlan", + "build_metadata_dict", + "build_output_plan", + "cleanup_output_staging", + "collect_directory_files", + "collect_staged_files", + "extract_archive_files", + "get_final_destination", + "get_supported_formats", + "is_managed_workspace_path", + "is_torrent_source", + "is_within_tmp_dir", + "log_plan_steps", + "prepare_output_files", + "process_directory", + "record_step", + "resolve_hardlink_source", + "safe_cleanup_path", + "scan_directory_tree", + "should_hardlink", + "transfer_book_files", + "transfer_directory_to_library", + "transfer_file_to_library", + "validate_destination", +] diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py new file mode 100644 index 0000000..ec8ff70 --- /dev/null +++ b/shelfmark/download/postprocess/policy.py @@ -0,0 +1,100 @@ +"""Post-download processing policy. + +This module holds configuration-driven *policy* decisions that are shared across +post-download processing components, but are not specific to archive extraction. + +Examples: +- Which file formats are enabled +- How files should be organized (none/rename/organize) +- Which naming templates to use + +Implementation note: +Keep this module free of dependencies on archive extraction mechanics to avoid +circular imports (`archive` is used by the pipeline). +""" + +from __future__ import annotations + +from typing import List + +import shelfmark.core.config as core_config + + +def get_supported_formats() -> List[str]: + """Get current supported formats from config singleton.""" + + formats = core_config.config.get( + "SUPPORTED_FORMATS", + ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], + ) + + # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) + if isinstance(formats, str): + return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] + + return [fmt.lower() for fmt in formats] + + +def get_supported_audiobook_formats() -> List[str]: + """Get current supported audiobook formats from config singleton.""" + + formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"]) + + # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) + if isinstance(formats, str): + return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] + + return [fmt.lower() for fmt in formats] + + +def get_file_organization(is_audiobook: bool) -> str: + """Get the file organization mode for the content type.""" + + key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION" + mode = core_config.config.get(key, "rename") + + # Handle legacy settings migration + if mode not in ("none", "rename", "organize"): + legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE" + legacy_mode = core_config.config.get(legacy_key, "ingest") + if legacy_mode == "library": + return "organize" + if core_config.config.get("USE_BOOK_TITLE", True): + return "rename" + return "none" + + return mode + + +def get_template(is_audiobook: bool, organization_mode: str) -> str: + """Get the template for the content type and organization mode.""" + + # Determine the correct key based on content type and organization mode + if is_audiobook: + if organization_mode == "organize": + key = "TEMPLATE_AUDIOBOOK_ORGANIZE" + else: + key = "TEMPLATE_AUDIOBOOK_RENAME" + else: + if organization_mode == "organize": + key = "TEMPLATE_ORGANIZE" + else: + key = "TEMPLATE_RENAME" + + template = core_config.config.get(key, "") + + # Fallback to legacy keys if new keys are empty + if not template: + legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE" + template = core_config.config.get(legacy_key, "") + + if not template: + legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE" + template = core_config.config.get(legacy_key, "") + + if not template: + if organization_mode == "organize": + return "{Author}/{Title} ({Year})" + return "{Author} - {Title} ({Year})" + + return template diff --git a/shelfmark/download/postprocess/prepare.py b/shelfmark/download/postprocess/prepare.py new file mode 100644 index 0000000..329b810 --- /dev/null +++ b/shelfmark/download/postprocess/prepare.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.download.archive import is_archive +from shelfmark.download.staging import STAGE_COPY, STAGE_NONE, get_staging_dir, stage_path + +from .scan import collect_staged_files +from .transfer import resolve_hardlink_source +from .types import OutputPlan, PreparedFiles +from .workspace import cleanup_output_staging, is_managed_workspace_path + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def build_output_plan( + temp_file: Path, + task: DownloadTask, + output_mode: str, + destination: Optional[Path] = None, + status_callback=None, +) -> OutputPlan: + """Build an output plan that describes staging behavior for file-based outputs.""" + + transfer_plan = resolve_hardlink_source(temp_file, task, destination, status_callback) + runs_custom_script = bool(core_config.config.CUSTOM_SCRIPT) and temp_file.is_file() and not is_archive(temp_file) + + stage_action = STAGE_COPY if runs_custom_script and not is_managed_workspace_path(temp_file) else STAGE_NONE + staging_dir = get_staging_dir() + + return OutputPlan( + mode=output_mode, + stage_action=stage_action, + staging_dir=staging_dir, + allow_archive_extraction=transfer_plan.allow_archive_extraction, + transfer_plan=transfer_plan, + ) + + +def prepare_output_files( + temp_file: Path, + task: DownloadTask, + output_mode: str, + status_callback, + destination: Optional[Path] = None, + output_plan: Optional[OutputPlan] = None, +) -> Optional[PreparedFiles]: + if output_plan is None: + output_plan = build_output_plan( + temp_file, + task, + output_mode=output_mode, + destination=destination, + status_callback=status_callback, + ) + + working_path = temp_file + if output_plan.stage_action != STAGE_NONE: + step_label = "Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging files" + status_callback("resolving", step_label) + working_path = stage_path(working_path, output_plan.staging_dir, output_plan.stage_action) + + can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(working_path) + + files, rejected_files, cleanup_paths, error = collect_staged_files( + working_path=working_path, + task=task, + allow_archive_extraction=output_plan.allow_archive_extraction, + status_callback=status_callback, + cleanup_archives=can_delete_source_archives, + ) + + if error: + status_callback("error", error) + cleanup_output_staging(output_plan, working_path, task, cleanup_paths) + return None + + if output_plan.stage_action == STAGE_NONE and is_managed_workspace_path(working_path): + cleanup_paths = [*cleanup_paths, working_path] + + return PreparedFiles( + output_plan=output_plan, + working_path=working_path, + files=files, + rejected_files=rejected_files, + cleanup_paths=cleanup_paths, + ) diff --git a/shelfmark/download/postprocess/router.py b/shelfmark/download/postprocess/router.py new file mode 100644 index 0000000..ef102c6 --- /dev/null +++ b/shelfmark/download/postprocess/router.py @@ -0,0 +1,52 @@ +"""Output routing for post-download processing. + +This module selects the appropriate output handler and invokes it. + +Keeping this separate from `pipeline.py` avoids circular imports: + +- output handlers depend on `pipeline` +- router depends on the output registry +""" + +from __future__ import annotations + +from pathlib import Path +from threading import Event +from typing import Optional + +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask, SearchMode +from shelfmark.download.outputs import resolve_output_handler + +logger = setup_logger(__name__) + + +def post_process_download( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + """Post-process download using the selected output handler.""" + + if task.search_mode is None: + logger.warning( + "Task %s: missing search_mode; defaulting to Direct mode behavior", + task.task_id, + ) + elif task.search_mode not in (SearchMode.DIRECT, SearchMode.UNIVERSAL): + logger.warning( + "Task %s: invalid search_mode=%s; defaulting to Direct mode behavior", + task.task_id, + task.search_mode, + ) + + output_handler = resolve_output_handler(task) + if output_handler: + logger.info("Task %s: using output mode %s", task.task_id, output_handler.mode) + return output_handler.handler(temp_file, task, cancel_flag, status_callback) + + from shelfmark.download.outputs.folder import process_folder_output + + logger.info("Task %s: using output mode folder", task.task_id) + return process_folder_output(temp_file, task, cancel_flag, status_callback) diff --git a/shelfmark/download/postprocess/scan.py b/shelfmark/download/postprocess/scan.py new file mode 100644 index 0000000..17fd9df --- /dev/null +++ b/shelfmark/download/postprocess/scan.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import List, Optional, Tuple + +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive +from shelfmark.download.permissions_debug import log_path_permission_context +from shelfmark.download.postprocess.policy import ( + get_supported_audiobook_formats, + get_supported_formats as get_book_formats, +) +from shelfmark.download.staging import build_staging_dir + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def get_supported_formats(content_type: Optional[str] = None) -> List[str]: + if check_audiobook(content_type): + return get_supported_audiobook_formats() + return get_book_formats() + + +def _format_not_supported_error(rejected_files: List[Path], task: DownloadTask) -> str: + content_type = task.content_type + file_type_label = "audiobook" if check_audiobook(content_type) else "book" + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + rejected_list = ", ".join(rejected_exts) + supported_formats = get_supported_formats(content_type) + + logger.warning( + "Task %s: found %d %s(s) but none match supported formats. Rejected formats: %s. Supported: %s", + task.task_id, + len(rejected_files), + file_type_label, + rejected_list, + ", ".join(sorted(supported_formats)), + ) + + return ( + f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). " + "Enable in Settings > Formats." + ) + + +def extract_archive_files( + archive_path: Path, + output_dir: Path, + task: DownloadTask, + cleanup_archive: bool, +) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: + content_type = task.content_type + + try: + extracted_files, warnings, rejected_files = extract_archive(archive_path, output_dir, content_type) + except ArchiveExtractionError as exc: + logger.warning( + "Task %s: archive extraction failed for %s: %s", + task.task_id, + archive_path.name, + exc, + ) + return [], [], [], str(exc) + + if warnings: + logger.debug( + "Task %s: archive warnings for %s: %s", + task.task_id, + archive_path.name, + "; ".join(warnings), + ) + + if cleanup_archive: + archive_path.unlink(missing_ok=True) + + cleanup_paths = [output_dir] + + if not extracted_files: + if rejected_files: + return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task) + file_type_label = "audiobook" if check_audiobook(content_type) else "book" + return [], rejected_files, cleanup_paths, f"No {file_type_label} files found in archive" + + logger.debug( + "Task %s: extracted %d file(s) from archive %s", + task.task_id, + len(extracted_files), + archive_path.name, + ) + + return extracted_files, rejected_files, cleanup_paths, None + + +def scan_directory_tree( + directory: Path, + content_type: Optional[str], +) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: + """Scan a directory tree for book files, trackable-but-unsupported files, and archives.""" + + try: + with os.scandir(directory) as it: + next(it, None) + except PermissionError as exc: + log_path_permission_context("scan_directory", directory) + logger.warning(f"Permission denied scanning directory: {directory} ({exc})") + return [], [], [], f"Permission denied accessing download folder: {directory}" + except (FileNotFoundError, NotADirectoryError, OSError) as exc: + logger.warning(f"Cannot access download folder: {directory} ({exc})") + return [], [], [], f"Cannot access download folder: {directory} ({exc})" + + book_files: List[Path] = [] + rejected_files: List[Path] = [] + archive_files: List[Path] = [] + + supported_formats = get_supported_formats(content_type) + supported_exts = {f".{fmt}" for fmt in supported_formats} + + is_audiobook = check_audiobook(content_type) + if is_audiobook: + trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'} + else: + trackable_exts = { + '.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', + '.doc', '.docx', '.rtf', '.txt', + } + + logged_walk_permission_context = False + + def onerror(error: OSError) -> None: + nonlocal logged_walk_permission_context + + if isinstance(error, PermissionError): + if not logged_walk_permission_context: + try: + error_path = Path(getattr(error, "filename", "") or str(directory)) + except Exception: + error_path = directory + + log_path_permission_context("scan_directory_walk", error_path) + logged_walk_permission_context = True + + logger.debug(f"Skipping inaccessible path during scan: {error}") + else: + logger.debug(f"Error scanning directory tree: {error}") + + for root, _, files in os.walk(directory, onerror=onerror): + for filename in files: + file_path = Path(root) / filename + suffix = file_path.suffix.lower() + + if suffix in supported_exts: + book_files.append(file_path) + elif suffix in trackable_exts: + rejected_files.append(file_path) + + if is_archive(file_path): + archive_files.append(file_path) + + return book_files, rejected_files, archive_files, None + + +def collect_directory_files( + directory: Path, + task: DownloadTask, + allow_archive_extraction: bool, + status_callback=None, + cleanup_archives: bool = False, +) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: + content_type = task.content_type + book_files, rejected_files, archive_files, scan_error = scan_directory_tree(directory, content_type) + if scan_error: + return [], [], [], scan_error + + if book_files: + if archive_files: + logger.debug( + "Task %s: ignoring %d archive(s) - already have %d book file(s)", + task.task_id, + len(archive_files), + len(book_files), + ) + if rejected_files: + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + logger.debug( + "Task %s: also found %d file(s) with unsupported formats: %s", + task.task_id, + len(rejected_files), + ", ".join(rejected_exts), + ) + return book_files, rejected_files, [], None + + if archive_files: + if not allow_archive_extraction: + logger.warning( + "Task %s: archive extraction disabled (torrent hardlinking enabled) for %s", + task.task_id, + directory, + ) + return [], rejected_files, [], "Archive extraction is disabled when torrent hardlinking is enabled" + + if status_callback: + status_callback("resolving", "Extracting archives") + + logger.info("Task %s: extracting %d archive(s)", task.task_id, len(archive_files)) + + all_files: List[Path] = [] + all_errors: List[str] = [] + cleanup_paths: List[Path] = [] + + for archive in archive_files: + extract_dir = build_staging_dir("extract", task.task_id) + extracted_files, archive_rejected, archive_cleanup, error = extract_archive_files( + archive_path=archive, + output_dir=extract_dir, + task=task, + cleanup_archive=cleanup_archives, + ) + + if error: + all_errors.append(f"{archive.name}: {error}") + if archive_rejected: + rejected_files.extend(archive_rejected) + if extracted_files: + all_files.extend(extracted_files) + if archive_cleanup: + cleanup_paths.extend(archive_cleanup) + + if all_files: + logger.info( + "Task %s: extracted %d file(s) from %d archive(s)", + task.task_id, + len(all_files), + len(archive_files), + ) + return all_files, rejected_files, cleanup_paths, None + + if all_errors: + return [], rejected_files, cleanup_paths, "; ".join(all_errors) + + if rejected_files: + return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task) + + return [], rejected_files, cleanup_paths, "No book files found in archives" + + if rejected_files: + return [], rejected_files, [], _format_not_supported_error(rejected_files, task) + + return [], rejected_files, [], "No book files found in download" + + +def collect_staged_files( + working_path: Path, + task: DownloadTask, + allow_archive_extraction: bool, + status_callback, + cleanup_archives: bool, +) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: + if working_path.is_dir(): + if status_callback: + status_callback("resolving", "Processing download folder") + return collect_directory_files( + working_path, + task, + allow_archive_extraction=allow_archive_extraction, + status_callback=status_callback, + cleanup_archives=cleanup_archives, + ) + + if is_archive(working_path) and allow_archive_extraction: + if status_callback: + status_callback("resolving", "Extracting archive") + + logger.info("Task %s: extracting archive %s", task.task_id, working_path.name) + + extract_dir = build_staging_dir("extract", task.task_id) + extracted_files, rejected_files, cleanup_paths, error = extract_archive_files( + archive_path=working_path, + output_dir=extract_dir, + task=task, + cleanup_archive=cleanup_archives, + ) + + if extracted_files: + logger.info( + "Task %s: extracted %d file(s) from archive %s", + task.task_id, + len(extracted_files), + working_path.name, + ) + + return extracted_files, rejected_files, cleanup_paths, error + + # Single-file download result (non-archive). + # Ensure we respect the user's supported format settings. + suffix = working_path.suffix.lower() + supported_formats = get_supported_formats(task.content_type) + supported_exts = {f".{fmt}" for fmt in supported_formats} + + is_audiobook = check_audiobook(task.content_type) + if is_audiobook: + trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'} + else: + trackable_exts = { + '.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', + '.doc', '.docx', '.rtf', '.txt', + } + + if suffix in supported_exts: + return [working_path], [], [], None + + if suffix in trackable_exts: + return [], [working_path], [], _format_not_supported_error([working_path], task) + + file_type_label = "audiobook" if is_audiobook else "book" + return [], [], [], f"Unsupported {file_type_label} file type: {suffix or working_path.name}" diff --git a/shelfmark/download/postprocess/steps.py b/shelfmark/download/postprocess/steps.py new file mode 100644 index 0000000..fda3f8f --- /dev/null +++ b/shelfmark/download/postprocess/steps.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any, List + +from shelfmark.core.logger import setup_logger + +from .types import PlanStep + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def record_step(steps: List[PlanStep], name: str, **details: Any) -> None: + steps.append(PlanStep(name=name, details=details)) + + +def log_plan_steps(task_id: str, steps: List[PlanStep]) -> None: + if not steps: + return + summary = " -> ".join(step.name for step in steps) + logger.debug("Processing plan for %s: %s", task_id, summary) diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py new file mode 100644 index 0000000..55fa2a0 --- /dev/null +++ b/shelfmark/download/postprocess/transfer.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import List, Optional, Tuple + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.naming import ( + assign_part_numbers, + build_library_path, + parse_naming_template, + same_filesystem, + sanitize_filename, +) +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.fs import atomic_copy, atomic_hardlink, atomic_move +from shelfmark.download.postprocess.policy import get_file_organization, get_template + +from .scan import collect_directory_files, scan_directory_tree +from .types import TransferPlan +from .workspace import safe_cleanup_path + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def should_hardlink(task: DownloadTask) -> bool: + """Check if hardlinking is enabled for this task (Prowlarr torrents only).""" + + if task.source != "prowlarr": + return False + + if not task.original_download_path: + return False + + is_audiobook = check_audiobook(task.content_type) + key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS" + + hardlink_enabled = core_config.config.get(key) + if hardlink_enabled is None: + hardlink_enabled = core_config.config.get("TORRENT_HARDLINK", False) + + return bool(hardlink_enabled) + + + +def build_metadata_dict(task: DownloadTask) -> dict: + return { + "Author": task.author, + "Title": task.title, + "Subtitle": task.subtitle, + "Year": task.year, + "Series": task.series_name, + "SeriesPosition": task.series_position, + } + + +def resolve_hardlink_source( + temp_file: Path, + task: DownloadTask, + destination: Optional[Path], + status_callback=None, +) -> TransferPlan: + """Resolve hardlink eligibility and source path for transfers.""" + + use_hardlink = False + source_path = temp_file + hardlink_enabled = should_hardlink(task) + + if hardlink_enabled and task.original_download_path: + hardlink_source = Path(task.original_download_path) + if destination and hardlink_source.exists() and same_filesystem(hardlink_source, destination): + use_hardlink = True + source_path = hardlink_source + elif hardlink_source.exists(): + logger.warning( + f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. " + "Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination." + ) + if status_callback: + status_callback("resolving", "Cannot hardlink (different filesystems), using copy") + + return TransferPlan( + source_path=source_path, + use_hardlink=use_hardlink, + allow_archive_extraction=not hardlink_enabled, + hardlink_enabled=hardlink_enabled, + ) + + +def is_torrent_source(source_path: Path, task: DownloadTask) -> bool: + """Check if source is the torrent client path (needs copy to preserve seeding).""" + + if not task.original_download_path: + return False + + original_path = Path(task.original_download_path) + try: + return source_path.resolve() == original_path.resolve() + except (OSError, ValueError): + try: + return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path)) + except Exception: + return False + + +def _transfer_single_file( + source_path: Path, + dest_path: Path, + use_hardlink: bool, + is_torrent: bool, + preserve_source: bool = False, +) -> Tuple[Path, str]: + if use_hardlink: + final_path = atomic_hardlink(source_path, dest_path) + try: + if os.stat(source_path).st_ino == os.stat(final_path).st_ino: + return final_path, "hardlink" + except OSError: + return final_path, "hardlink" + return final_path, "copy" + + if is_torrent or preserve_source: + return atomic_copy(source_path, dest_path), "copy" + + return atomic_move(source_path, dest_path), "move" + + +def transfer_book_files( + book_files: List[Path], + destination: Path, + task: DownloadTask, + use_hardlink: bool, + is_torrent: bool, + preserve_source: bool = False, + organization_mode: Optional[str] = None, +) -> Tuple[List[Path], Optional[str]]: + if not book_files: + return [], "No book files found" + + is_audiobook = check_audiobook(task.content_type) + organization_mode = organization_mode or get_file_organization(is_audiobook) + + final_paths: List[Path] = [] + + if organization_mode == "organize": + template = get_template(is_audiobook, "organize") + metadata = build_metadata_dict(task) + + if len(book_files) == 1: + source_file = book_files[0] + ext = source_file.suffix.lstrip(".") or task.format or "" + dest_path = build_library_path(str(destination), template, metadata, extension=ext or None) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + final_path, op = _transfer_single_file( + source_file, + dest_path, + use_hardlink, + is_torrent, + preserve_source=preserve_source, + ) + final_paths.append(final_path) + logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + else: + zero_pad_width = max(len(str(len(book_files))), 2) + files_with_parts = assign_part_numbers(book_files, zero_pad_width) + + for source_file, part_number in files_with_parts: + ext = source_file.suffix.lstrip(".") or task.format or "" + file_metadata = {**metadata, "PartNumber": part_number} + dest_path = build_library_path(str(destination), template, file_metadata, extension=ext or None) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + final_path, op = _transfer_single_file( + source_file, + dest_path, + use_hardlink, + is_torrent, + preserve_source=preserve_source, + ) + final_paths.append(final_path) + logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + + return final_paths, None + + for book_file in book_files: + if len(book_files) == 1 and organization_mode != "none": + if not task.format: + task.format = book_file.suffix.lower().lstrip(".") + + template = get_template(is_audiobook, "rename") + metadata = build_metadata_dict(task) + extension = book_file.suffix.lstrip(".") or task.format or "" + + filename = parse_naming_template(template, metadata) + filename = Path(filename).name if filename else "" + if filename and extension: + filename = f"{sanitize_filename(filename)}.{extension}" + else: + filename = book_file.name + else: + filename = book_file.name + + dest_path = destination / filename + final_path, op = _transfer_single_file( + book_file, + dest_path, + use_hardlink, + is_torrent, + preserve_source=preserve_source, + ) + final_paths.append(final_path) + logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + + return final_paths, None + + +def process_directory( + directory: Path, + ingest_dir: Path, + task: DownloadTask, + allow_archive_extraction: bool = True, + use_hardlink: Optional[bool] = None, +) -> Tuple[List[Path], Optional[str]]: + """Process staged directory: find book files, extract archives, move to ingest.""" + + try: + is_torrent = is_torrent_source(directory, task) + book_files, _, cleanup_paths, error = collect_directory_files( + directory, + task, + allow_archive_extraction=allow_archive_extraction, + status_callback=None, + cleanup_archives=not is_torrent, + ) + + if error: + if not is_torrent: + safe_cleanup_path(directory, task) + for cleanup_path in cleanup_paths: + safe_cleanup_path(cleanup_path, task) + return [], error + + if use_hardlink is None: + use_hardlink = should_hardlink(task) + + final_paths, error = transfer_book_files( + book_files, + destination=ingest_dir, + task=task, + use_hardlink=use_hardlink, + is_torrent=is_torrent, + ) + + if error: + return [], error + + if not is_torrent: + safe_cleanup_path(directory, task) + for cleanup_path in cleanup_paths: + safe_cleanup_path(cleanup_path, task) + + return final_paths, None + + except Exception as exc: + logger.error_trace("Task %s: error processing directory %s: %s", task.task_id, directory, exc) + if not is_torrent_source(directory, task): + safe_cleanup_path(directory, task) + return [], str(exc) + + +def transfer_file_to_library( + source_path: Path, + library_base: str, + template: str, + metadata: dict, + task: DownloadTask, + temp_file: Optional[Path], + status_callback, + use_hardlink: bool, +) -> Optional[str]: + extension = source_path.suffix.lstrip(".") or task.format + dest_path = build_library_path(library_base, template, metadata, extension) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + is_torrent = is_torrent_source(source_path, task) + final_path, op = _transfer_single_file(source_path, dest_path, use_hardlink, is_torrent) + logger.info(f"Library {op}: {final_path}") + + if use_hardlink and temp_file and not is_torrent_source(temp_file, task): + safe_cleanup_path(temp_file, task) + + status_callback("complete", "Complete") + return str(final_path) + + +def transfer_directory_to_library( + source_dir: Path, + library_base: str, + template: str, + metadata: dict, + task: DownloadTask, + temp_file: Optional[Path], + status_callback, + use_hardlink: bool, +) -> Optional[str]: + content_type = task.content_type.lower() if task.content_type else None + source_files, _, _, scan_error = scan_directory_tree(source_dir, content_type) + if scan_error: + logger.warning(scan_error) + status_callback("error", scan_error) + if temp_file: + safe_cleanup_path(temp_file, task) + return None + + if not source_files: + logger.warning(f"No supported files in {source_dir.name}") + status_callback("error", "No supported file formats found") + if temp_file: + safe_cleanup_path(temp_file, task) + return None + + base_library_path = build_library_path(library_base, template, metadata, extension=None) + base_library_path.parent.mkdir(parents=True, exist_ok=True) + + is_torrent = is_torrent_source(source_dir, task) + transferred_paths: List[Path] = [] + + if len(source_files) == 1: + source_file = source_files[0] + ext = source_file.suffix.lstrip(".") + dest_path = base_library_path.with_suffix(f".{ext}") + final_path, op = _transfer_single_file(source_file, dest_path, use_hardlink, is_torrent) + logger.debug(f"Library {op}: {source_file.name} -> {final_path}") + transferred_paths.append(final_path) + else: + zero_pad_width = max(len(str(len(source_files))), 2) + files_with_parts = assign_part_numbers(source_files, zero_pad_width) + + for source_file, part_number in files_with_parts: + ext = source_file.suffix.lstrip(".") + file_metadata = {**metadata, "PartNumber": part_number} + file_path = build_library_path(library_base, template, file_metadata, extension=ext) + file_path.parent.mkdir(parents=True, exist_ok=True) + + final_path, op = _transfer_single_file(source_file, file_path, use_hardlink, is_torrent) + logger.debug(f"Library {op}: {source_file.name} -> {final_path}") + transferred_paths.append(final_path) + + if use_hardlink: + operation = "hardlinks" + elif is_torrent: + operation = "copies" + else: + operation = "files" + logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}") + + if use_hardlink and temp_file and not is_torrent_source(temp_file, task): + safe_cleanup_path(temp_file, task) + elif not is_torrent: + safe_cleanup_path(temp_file, task) + safe_cleanup_path(source_dir, task) + + message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete" + status_callback("complete", message) + + return str(transferred_paths[0]) diff --git a/shelfmark/download/postprocess/types.py b/shelfmark/download/postprocess/types.py new file mode 100644 index 0000000..f8f3a7e --- /dev/null +++ b/shelfmark/download/postprocess/types.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +from shelfmark.download.staging import StageAction + + +@dataclass(frozen=True) +class TransferPlan: + source_path: Path + use_hardlink: bool + allow_archive_extraction: bool + hardlink_enabled: bool + + +@dataclass(frozen=True) +class OutputPlan: + mode: str + stage_action: StageAction + staging_dir: Path + allow_archive_extraction: bool + transfer_plan: Optional[TransferPlan] = None + + +@dataclass(frozen=True) +class PreparedFiles: + output_plan: OutputPlan + working_path: Path + files: List[Path] + rejected_files: List[Path] + cleanup_paths: List[Path] + + +@dataclass(frozen=True) +class PlanStep: + name: str + details: Dict[str, Any] diff --git a/shelfmark/download/postprocess/workspace.py b/shelfmark/download/postprocess/workspace.py new file mode 100644 index 0000000..37323e5 --- /dev/null +++ b/shelfmark/download/postprocess/workspace.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import List, Optional + +from shelfmark.config import env as env_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.download.staging import STAGE_NONE + +from .types import OutputPlan + +logger = setup_logger("shelfmark.download.postprocess.pipeline") + + +def _tmp_dir() -> Path: + return env_config.TMP_DIR + + +def is_within_tmp_dir(path: Path) -> bool: + """Legacy helper: True if path is inside TMP_DIR.""" + + try: + path.resolve().relative_to(_tmp_dir().resolve()) + return True + except (OSError, ValueError): + return False + + +def is_managed_workspace_path(path: Path) -> bool: + """True if Shelfmark should treat this path as mutable. + + The managed workspace is `TMP_DIR`. Anything outside it should be treated as + read-only for safety (e.g. torrent seeding directories). + """ + + return is_within_tmp_dir(path) + + +def _is_original_download(path: Optional[Path], task: DownloadTask) -> bool: + if not path or not task.original_download_path: + return False + try: + return path.resolve() == Path(task.original_download_path).resolve() + except (OSError, ValueError): + return False + + +def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None: + """Remove a temp path only if it is safe and in our managed workspace.""" + + if not path or _is_original_download(path, task): + return + + if not is_managed_workspace_path(path): + logger.debug("Skip cleanup (outside TMP_DIR) for task %s: %s", task.task_id, path) + return + + try: + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + elif path.exists(): + path.unlink(missing_ok=True) + except (OSError, PermissionError) as exc: + logger.warning("Cleanup failed for task %s (%s): %s", task.task_id, path, exc) + + +def cleanup_output_staging( + output_plan: OutputPlan, + working_path: Path, + task: DownloadTask, + cleanup_paths: Optional[List[Path]] = None, +) -> None: + if output_plan.stage_action != STAGE_NONE: + cleanup_target = output_plan.staging_dir + if output_plan.staging_dir == _tmp_dir(): + cleanup_target = working_path + safe_cleanup_path(cleanup_target, task) + + if cleanup_paths: + for path in cleanup_paths: + safe_cleanup_path(path, task) diff --git a/shelfmark/download/staging.py b/shelfmark/download/staging.py new file mode 100644 index 0000000..118caf2 --- /dev/null +++ b/shelfmark/download/staging.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import hashlib +import shutil +from pathlib import Path +from typing import Literal + +from shelfmark.config import env as env_config +from shelfmark.core.logger import setup_logger + +logger = setup_logger(__name__) + +StageAction = Literal["none", "copy", "move"] +STAGE_NONE: StageAction = "none" +STAGE_COPY: StageAction = "copy" +STAGE_MOVE: StageAction = "move" + + +def get_staging_dir() -> Path: + """Get the staging directory for downloads.""" + tmp_dir = env_config.TMP_DIR + tmp_dir.mkdir(parents=True, exist_ok=True) + return tmp_dir + + +def get_staging_path(task_id: str, extension: str) -> Path: + """Get a staging path for a download.""" + staging_dir = get_staging_dir() + safe_id = hashlib.md5(task_id.encode()).hexdigest()[:16] + return staging_dir / f"{safe_id}.{extension.lstrip('.')}" + + +def build_staging_dir(prefix: str | None, task_id: str) -> Path: + """Build a dedicated staging directory for output processing.""" + base_dir = get_staging_dir() + if not prefix: + return base_dir + + safe_id = hashlib.md5(task_id.encode()).hexdigest()[:8] + staging_dir = base_dir / f"{prefix}_{safe_id}" + counter = 1 + + while staging_dir.exists(): + staging_dir = base_dir / f"{prefix}_{safe_id}_{counter}" + counter += 1 + + staging_dir.mkdir(parents=True, exist_ok=True) + return staging_dir + + +def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: + """Stage a file for ingest processing. Use copy=True for torrents to preserve seeding.""" + staging_dir = get_staging_dir() + return stage_path(source_path, staging_dir, STAGE_COPY if copy else STAGE_MOVE) + + +def stage_path(source: Path, staging_dir: Path, action: StageAction) -> Path: + """Stage a file or directory into a staging dir.""" + if action == STAGE_NONE: + return source + + staged_path = staging_dir / source.name + counter = 1 + + if source.is_dir(): + while staged_path.exists(): + staged_path = staging_dir / f"{source.name}_{counter}" + counter += 1 + if action == STAGE_COPY: + shutil.copytree(str(source), str(staged_path)) + else: + shutil.move(str(source), str(staged_path)) + else: + while staged_path.exists(): + staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}" + counter += 1 + if action == STAGE_COPY: + shutil.copy2(str(source), str(staged_path)) + else: + shutil.move(str(source), str(staged_path)) + + staged_kind = "directory" if source.is_dir() else "file" + logger.debug("Staged %s via %s: %s -> %s", staged_kind, action, source, staged_path) + return staged_path diff --git a/shelfmark/main.py b/shelfmark/main.py index b34bf26..9c5533d 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -744,7 +744,7 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/download//cancel', methods=['DELETE']) +@app.route('/api/download//cancel', methods=['DELETE']) @login_required def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: """ @@ -765,7 +765,7 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Cancel download error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/queue//priority', methods=['PUT']) +@app.route('/api/queue//priority', methods=['PUT']) @login_required def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]: """ diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 67bc23c..9edfe1b 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -159,6 +159,101 @@ class BookMetadata: titles_by_language: Dict[str, str] = field(default_factory=dict) +def group_languages_by_localized_title( + base_title: str, + languages: Optional[List[str]], + titles_by_language: Optional[Dict[str, str]] = None, +) -> List[tuple[str, Optional[List[str]]]]: + """Group language codes by localized title. + + Release sources that support language filtering (e.g., Anna's Archive) + may want to run separate searches per localized title, while still + passing the correct language filters per query. + + Args: + base_title: Fallback title when no localized title exists. + languages: Requested language codes (e.g., ["en", "hu"]). + titles_by_language: Mapping of language identifiers to localized titles. + + Returns: + List of (title, languages) tuples. If languages is None/empty, returns + [(base_title, None)]. + """ + if not base_title: + return [] + + if not languages: + return [(base_title, None)] + + normalized_langs = [lang.strip() for lang in languages if lang and lang.strip()] + if not normalized_langs: + return [(base_title, None)] + + if not titles_by_language: + return [(base_title, normalized_langs)] + + title_to_langs: Dict[str, List[str]] = {} + for lang in normalized_langs: + localized_title = titles_by_language.get(lang) or base_title + title_to_langs.setdefault(localized_title, []).append(lang) + + return list(title_to_langs.items()) + + +def build_localized_search_titles( + base_title: str, + languages: Optional[List[str]], + titles_by_language: Optional[Dict[str, str]] = None, + excluded_languages: Optional[set[str]] = None, +) -> List[str]: + """Build a list of titles to search for, including localized editions. + + This is useful for release sources that *can't* pass language filters to + an upstream search API (e.g., Prowlarr), but still want to broaden matches + by searching for localized edition titles. + + The list always includes base_title first. + + Args: + base_title: Primary title to search for. + languages: User language preferences (order matters). + titles_by_language: Mapping of language identifiers to localized titles. + excluded_languages: Optional set of normalized language identifiers to skip. + + Returns: + List of unique titles to search for, in priority order. + """ + if not base_title: + return [] + + titles: List[str] = [base_title] + seen = {base_title} + + if not languages or not titles_by_language: + return titles + + excluded = {lang.lower() for lang in (excluded_languages or set())} + + for lang in languages: + if not lang: + continue + normalized_lang = lang.strip() + if not normalized_lang: + continue + if normalized_lang.lower() in excluded: + continue + + localized_title = titles_by_language.get(normalized_lang) + if not localized_title: + continue + + if localized_title not in seen: + seen.add(localized_title) + titles.append(localized_title) + + return titles + + @dataclass class SearchResult: """Result from a metadata search with pagination info.""" diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 4055431..195eb62 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -390,7 +390,11 @@ class HardcoverProvider(MetadataProvider): primary_books_count } } - editions(limit: 20, order_by: {users_count: desc}) { + editions( + distinct_on: language_id + order_by: [{language_id: asc}, {users_count: desc}] + limit: 200 + ) { title language { language @@ -835,7 +839,6 @@ def hardcover_settings(): label="API Key", description="Get your API key from hardcover.app/account/api", required=True, - env_supported=False, # UI-only setting, no ENV var support ), ActionButton( key="test_connection", @@ -850,7 +853,6 @@ def hardcover_settings(): description="Default sort order for Hardcover search results.", options=_HARDCOVER_SORT_OPTIONS, default="relevance", - env_supported=False, # UI-only setting ), CheckboxField( key="HARDCOVER_EXCLUDE_COMPILATIONS", diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py index 63c3033..2cd8a74 100644 --- a/shelfmark/metadata_providers/openlibrary.py +++ b/shelfmark/metadata_providers/openlibrary.py @@ -558,6 +558,5 @@ def openlibrary_settings(): description="Default sort order for Open Library search results.", options=_OPENLIBRARY_SORT_OPTIONS, default="relevance", - env_supported=False, # UI-only setting ), ] diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index 0d3bbb0..1edd1c4 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -252,8 +252,14 @@ class ReleaseSource(ABC): class DownloadHandler(ABC): - """Interface for executing downloads. Handlers stage files to TMP_DIR; - orchestrator handles post-processing and move to INGEST_DIR. + """Interface for executing downloads. + + A handler may either: + - download directly into ``TMP_DIR`` (managed by Shelfmark), or + - return a path owned by an external client (e.g. torrent/usenet). + + The orchestrator is responsible for post-processing (archive extraction, output mode + handling) and transferring files into their final destination. """ @abstractmethod @@ -264,9 +270,17 @@ class DownloadHandler(ABC): progress_callback: Callable[[float], None], status_callback: Callable[[str, Optional[str]], None] ) -> Optional[str]: - """Execute download and return path to staged file in TMP_DIR.""" + """Execute download and return a path to the downloaded payload.""" pass + def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: + """Optional hook called after orchestrator post-processing. + + This is primarily used for external download clients, where the handler may need + to trigger client-side cleanup only after Shelfmark has safely imported the files. + """ + return + @abstractmethod def cancel(self, task_id: str) -> bool: """Cancel an in-progress download.""" diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 08fd54a..729657f 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -20,7 +20,7 @@ from shelfmark.core.config import config from shelfmark.core.utils import CONTENT_TYPES from shelfmark.core.logger import setup_logger from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask -from shelfmark.metadata_providers import BookMetadata +from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title from shelfmark.release_sources import ( Release, ReleaseProtocol, @@ -1181,14 +1181,11 @@ class DirectDownloadSource(ReleaseSource): author = book.authors[0] if book.authors else "" # Group languages by localized title to avoid duplicate searches - if lang_filter and book.titles_by_language: - title_to_langs: Dict[str, List[str]] = {} - for lang in lang_filter: - title = book.titles_by_language.get(lang, book.title) - title_to_langs.setdefault(title, []).append(lang) - searches = list(title_to_langs.items()) - else: - searches = [(book.title, lang_filter)] + searches = group_languages_by_localized_title( + base_title=book.title, + languages=lang_filter, + titles_by_language=book.titles_by_language, + ) # Execute searches with deduplication seen_ids: set = set() @@ -1300,7 +1297,7 @@ class DirectDownloadHandler(DownloadHandler): handle bypass, move to final location. """ try: - logger.info(f"Starting download: {book_info.title}") + logger.debug("Starting download: %s", book_info.title) # Prepare paths - use descriptive staging filename, orchestrator will rename # based on FILE_ORGANIZATION setting diff --git a/shelfmark/release_sources/irc/handler.py b/shelfmark/release_sources/irc/handler.py index 83c3880..614b1ef 100644 --- a/shelfmark/release_sources/irc/handler.py +++ b/shelfmark/release_sources/irc/handler.py @@ -100,7 +100,7 @@ class IRCDownloadHandler(DownloadHandler): ext = Path(offer.filename).suffix.lstrip('.') or task.format or "epub" # Stage to temp directory (lazy import to avoid circular import) - from shelfmark.download.orchestrator import get_staging_path + from shelfmark.download.staging import get_staging_path staging_path = get_staging_path(task.task_id, ext) download_dcc( diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index d4163bd..8af842c 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -1,7 +1,6 @@ """Prowlarr API client for connection testing, indexer listing, and search.""" from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urljoin import requests @@ -31,7 +30,7 @@ class ProwlarrClient: json_data: Optional[Dict[str, Any]] = None, ) -> Any: """Make an API request to Prowlarr. Returns parsed JSON response.""" - url = urljoin(self.base_url, endpoint) + url = self.base_url + endpoint logger.debug(f"Prowlarr API: {method} {url}") try: diff --git a/shelfmark/release_sources/prowlarr/clients/__init__.py b/shelfmark/release_sources/prowlarr/clients/__init__.py index ef7476b..510e84a 100644 --- a/shelfmark/release_sources/prowlarr/clients/__init__.py +++ b/shelfmark/release_sources/prowlarr/clients/__init__.py @@ -11,13 +11,87 @@ Clients register themselves via the @register_client decorator. """ import logging +import os +import random +import time from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum -from typing import Dict, List, Optional, Tuple, Type, Union +from functools import wraps +from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union + +import requests _logger = logging.getLogger(__name__) +# Type variable for generic return type +T = TypeVar('T') + +# Exceptions that should trigger a retry +RETRYABLE_EXCEPTIONS = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.HTTPError, +) + + +def with_retry( + max_attempts: int = 3, + base_delay: float = 1.0, + max_delay: float = 10.0, + jitter: float = 0.5, +) -> Callable[[Callable[..., T]], Callable[..., T]]: + """ + Decorator for retrying API calls with exponential backoff. + + Args: + max_attempts: Maximum number of attempts (default 3) + base_delay: Initial delay in seconds (default 1.0) + max_delay: Maximum delay cap in seconds (default 10.0) + jitter: Random jitter factor 0-1 to add to delay (default 0.5) + + Retries on: + - Connection errors + - Timeouts + - HTTP 5xx server errors + + Does NOT retry on: + - HTTP 4xx client errors (bad request, auth failures) + - Other exceptions (programming errors) + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @wraps(func) + def wrapper(*args, **kwargs) -> T: + last_exception = None + + for attempt in range(1, max_attempts + 1): + try: + return func(*args, **kwargs) + except requests.exceptions.HTTPError as e: + # Only retry on server errors (5xx), not client errors (4xx) + if e.response is not None and e.response.status_code < 500: + raise + last_exception = e + except RETRYABLE_EXCEPTIONS as e: + last_exception = e + + if attempt < max_attempts: + # Calculate delay with exponential backoff + delay = min(base_delay * (2 ** (attempt - 1)), max_delay) + # Add jitter to prevent thundering herd + delay += random.uniform(0, delay * jitter) + _logger.debug( + f"Retry {attempt}/{max_attempts} for {func.__name__} " + f"after {delay:.1f}s (error: {last_exception})" + ) + time.sleep(delay) + + # All retries exhausted + raise last_exception + + return wrapper + return decorator + class DownloadState(Enum): """Valid states for a download.""" @@ -97,6 +171,49 @@ class DownloadClient(ABC): protocol: str name: str + def _log_error(self, method: str, e: Exception, level: str = "error") -> str: + """ + Log a client error with consistent formatting. + + Args: + method: Name of the method that failed (e.g., "get_status") + e: The exception that was raised + level: Log level - "error" or "debug" + + Returns: + Formatted error message string (for use in DownloadStatus.error()) + """ + error_type = type(e).__name__ + msg = f"{self.name} {method} failed ({error_type}): {e}" + if level == "debug": + _logger.debug(msg) + else: + _logger.error(msg) + + # Reset connection state if client tracks it (e.g., Deluge) + if hasattr(self, "_connected"): + self._connected = False + + return f"{error_type}: {e}" + + def _build_path(self, *components: str) -> Optional[str]: + """ + Safely build a file path from components. + + Args: + *components: Path components to join (e.g., save_path, name) + + Returns: + Normalized path string, or None if any component is empty/None. + """ + # Filter out empty/None components + valid = [c for c in components if c] + if len(valid) != len(components): + return None + + # Join and normalize + return os.path.normpath(os.path.join(*valid)) + def __init_subclass__(cls, **kwargs): """Validate that subclasses define required class attributes.""" super().__init_subclass__(**kwargs) @@ -139,14 +256,13 @@ class DownloadClient(ABC): pass @abstractmethod - def add_download(self, url: str, name: str, category: str = "cwabd") -> str: - """ - Add a download to the client. + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: + """Add a download to the client. Args: url: Download URL (magnet link, .torrent URL, or NZB URL) name: Display name for the download - category: Category/label for organization + category: Category/label for organization (None = client default) Returns: Client-specific download ID (hash for torrents, ID for NZBGet). diff --git a/shelfmark/release_sources/prowlarr/clients/deluge.py b/shelfmark/release_sources/prowlarr/clients/deluge.py index 349e0df..aed55ee 100644 --- a/shelfmark/release_sources/prowlarr/clients/deluge.py +++ b/shelfmark/release_sources/prowlarr/clients/deluge.py @@ -1,14 +1,21 @@ -""" -Deluge download client for Prowlarr integration. +"""Deluge download client for Prowlarr integration. -Uses the deluge-client library to communicate with Deluge's RPC daemon. -Note: Deluge uses a custom binary RPC protocol over TCP (default port 58846, -configurable via DELUGE_PORT), which requires the daemon to have -"Allow Remote Connections" enabled. +This implementation talks to Deluge via the Web UI JSON-RPC API (``/json``). + +Why Web UI API instead of daemon RPC (port 58846)? +- Matches the approach used by common automation apps (e.g. Sonarr/Radarr) +- Avoids requiring Deluge daemon ``auth`` file credentials (username/password) + +Requirements: +- ``deluge-web`` must be enabled and reachable from Shelfmark +- Deluge Web UI must be connected (or connectable) to a Deluge daemon """ import base64 from typing import Any, Optional, Tuple +from urllib.parse import urlparse + +import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -24,146 +31,224 @@ from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( logger = setup_logger(__name__) -def _decode(value: Any) -> Any: - """Decode bytes to string if needed (Deluge returns bytes for strings).""" - return value.decode('utf-8') if isinstance(value, bytes) else value +class DelugeRpcError(RuntimeError): + def __init__(self, message: str, code: int | None = None): + super().__init__(message) + self.code = code + + +def _get_error_message(error: Any) -> Tuple[str, int | None]: + if isinstance(error, dict): + return str(error.get("message") or error), error.get("code") + return str(error), None @register_client("torrent") class DelugeClient(DownloadClient): - """Deluge download client using deluge-client RPC library.""" + """Deluge download client using Deluge Web UI JSON-RPC.""" protocol = "torrent" name = "deluge" def __init__(self): - """Initialize Deluge client with settings from config.""" - from deluge_client import DelugeRPCClient + raw_host = str(config.get("DELUGE_HOST", "localhost") or "") + raw_port = str(config.get("DELUGE_PORT", "8112") or "8112") + password = str(config.get("DELUGE_PASSWORD", "") or "") - host = config.get("DELUGE_HOST", "localhost") - password = config.get("DELUGE_PASSWORD", "") - - if not host: + if not raw_host: raise ValueError("DELUGE_HOST is required") if not password: raise ValueError("DELUGE_PASSWORD is required") - port = int(config.get("DELUGE_PORT", "58846")) - username = config.get("DELUGE_USERNAME", "") + scheme = "http" + base_path = "" - self._client = DelugeRPCClient( - host=host, - port=port, - username=username, - password=password, - ) + # Allow DELUGE_HOST to be either a hostname OR a full URL + # (useful when Deluge is behind a reverse proxy path). + host = raw_host + port = int(raw_port) + + if raw_host.startswith(("http://", "https://")): + parsed = urlparse(raw_host) + scheme = parsed.scheme or "http" + host = parsed.hostname or "localhost" + if parsed.port is not None: + port = parsed.port + base_path = (parsed.path or "").rstrip("/") + else: + # Allow "host:port" in DELUGE_HOST for convenience. + if ":" in raw_host and raw_host.count(":") == 1: + host_part, port_part = raw_host.split(":", 1) + if host_part and port_part.isdigit(): + host = host_part + port = int(port_part) + + self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json" + self._password = password + self._session = requests.Session() + + self._authenticated = False self._connected = False - self._category = config.get("DELUGE_CATEGORY", "cwabd") + self._rpc_id = 0 - def _ensure_connected(self): - """Ensure we're connected to the Deluge daemon.""" - if not self._connected: - logger.debug("Connecting to Deluge daemon...") + self._category = str(config.get("DELUGE_CATEGORY", "cwabd") or "cwabd") + + def _next_rpc_id(self) -> int: + self._rpc_id += 1 + return self._rpc_id + + def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any: + payload = { + "id": self._next_rpc_id(), + "method": method, + "params": list(params), + } + + response = self._session.post(self._rpc_url, json=payload, timeout=timeout) + response.raise_for_status() + + data = response.json() + if data.get("error"): + message, code = _get_error_message(data["error"]) + raise DelugeRpcError(message, code) + + return data.get("result") + + def _login(self) -> None: + result = self._rpc_call("auth.login", self._password) + if result is not True: + raise DelugeRpcError("Deluge Web UI authentication failed") + self._authenticated = True + + def _select_daemon_host_id(self, hosts: list) -> str: + # Hosts returned by web.get_hosts look like: + # [[host_id, host, port, status], ...] + preferred_hosts = {"127.0.0.1", "localhost"} + + for entry in hosts: + if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts: + return str(entry[0]) + + for entry in hosts: + if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online": + return str(entry[0]) + + return str(hosts[0][0]) + + def _ensure_connected(self) -> None: + if not self._authenticated: + self._login() + + if self._connected: + return + + if self._rpc_call("web.connected") is True: + self._connected = True + return + + hosts = self._rpc_call("web.get_hosts") or [] + if not hosts: + raise DelugeRpcError( + "Deluge Web UI isn't connected to Deluge core (no hosts configured). " + "Add/connect a daemon in Deluge Web UI → Connection Manager." + ) + + host_id = self._select_daemon_host_id(hosts) + self._rpc_call("web.connect", host_id) + + if self._rpc_call("web.connected") is not True: + raise DelugeRpcError( + "Deluge Web UI couldn't connect to Deluge core. " + "Check daemon status in Deluge Web UI → Connection Manager." + ) + + self._connected = True + + def _try_set_label(self, torrent_id: str, label: str) -> None: + """Best-effort label assignment (requires Deluge Label plugin).""" + if not label: + return + + try: + # label.add will error if the plugin is unavailable or the label exists. try: - self._client.connect() - self._connected = True - logger.debug("Connected to Deluge daemon") - except Exception as e: - logger.error(f"Failed to connect to Deluge daemon: {type(e).__name__}: {e}") - raise + self._rpc_call("label.add", label) + except Exception: + pass + + self._rpc_call("label.set_torrent", torrent_id, label) + except Exception as e: + logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}") @staticmethod def is_configured() -> bool: - """Check if Deluge is configured and selected as the torrent client.""" client = config.get("PROWLARR_TORRENT_CLIENT", "") host = config.get("DELUGE_HOST", "") password = config.get("DELUGE_PASSWORD", "") return client == "deluge" and bool(host) and bool(password) def test_connection(self) -> Tuple[bool, str]: - """Test connection to Deluge.""" try: self._ensure_connected() - # Get daemon info - version = self._client.call('daemon.info') + version = self._rpc_call("daemon.info") return True, f"Connected to Deluge {version}" except Exception as e: + self._authenticated = False self._connected = False return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: - """ - Add torrent by URL (magnet or .torrent). - - Args: - url: Magnet link or .torrent URL - name: Display name for the torrent - category: Category for organization (uses configured default if not specified) - - Returns: - Torrent hash (info_hash). - - Raises: - Exception: If adding fails. - """ + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: try: self._ensure_connected() - category = category or self._category + category_value = str(category or self._category) torrent_info = extract_torrent_info(url) if not torrent_info.is_magnet and not torrent_info.torrent_data: raise Exception("Failed to fetch torrent file") - options = {} + options: dict[str, Any] = {} if torrent_info.is_magnet: - # Use magnet URL if available, otherwise original URL magnet_url = torrent_info.magnet_url or url - torrent_id = self._client.call( - 'core.add_torrent_magnet', - magnet_url, - options, - ) + torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options) else: - filedump = base64.b64encode(torrent_info.torrent_data).decode('ascii') - torrent_id = self._client.call( - 'core.add_torrent_file', + torrent_data = torrent_info.torrent_data + if torrent_data is None: + raise Exception("Failed to fetch torrent file") + + torrent_data_bytes: bytes = torrent_data + filedump = base64.b64encode(torrent_data_bytes).decode("ascii") + torrent_id = self._rpc_call( + "core.add_torrent_file", f"{name}.torrent", filedump, options, ) - if torrent_id: - torrent_id = _decode(torrent_id) - logger.info(f"Added torrent to Deluge: {torrent_id}") - return torrent_id.lower() + if not torrent_id: + raise Exception("Deluge returned no torrent ID") - raise Exception("Deluge returned no torrent ID") + torrent_id = str(torrent_id).lower() + self._try_set_label(torrent_id, category_value) + + logger.info(f"Added torrent to Deluge: {torrent_id}") + return torrent_id except Exception as e: + self._authenticated = False self._connected = False logger.error(f"Deluge add failed: {e}") raise def get_status(self, download_id: str) -> DownloadStatus: - """ - Get torrent status by hash. - - Args: - download_id: Torrent info_hash - - Returns: - Current download status. - """ try: self._ensure_connected() - # Get torrent status - status = self._client.call( - 'core.get_torrent_status', + status = self._rpc_call( + "core.get_torrent_status", download_id, - ['state', 'progress', 'download_payload_rate', 'eta', 'save_path', 'name'], + ["state", "progress", "download_payload_rate", "eta", "save_path", "name"], ) if not status: @@ -171,35 +256,42 @@ class DelugeClient(DownloadClient): # Deluge states: Downloading, Seeding, Paused, Checking, Queued, Error, Moving state_map = { - 'Downloading': ('downloading', None), - 'Seeding': ('seeding', 'Seeding'), - 'Paused': ('paused', 'Paused'), - 'Checking': ('checking', 'Checking files'), - 'Queued': ('queued', 'Queued'), - 'Error': ('error', 'Error'), - 'Moving': ('processing', 'Moving files'), - 'Allocating': ('downloading', 'Allocating space'), + "Downloading": ("downloading", None), + "Seeding": ("seeding", "Seeding"), + "Paused": ("paused", "Paused"), + "Checking": ("checking", "Checking files"), + "Queued": ("queued", "Queued"), + "Error": ("error", "Error"), + "Moving": ("processing", "Moving files"), + "Allocating": ("downloading", "Allocating space"), } - deluge_state = _decode(status.get(b'state', b'Unknown')) - state, message = state_map.get(deluge_state, ('unknown', deluge_state)) - progress = status.get(b'progress', 0) + deluge_state = status.get("state", "Unknown") + state, message = state_map.get(str(deluge_state), ("unknown", str(deluge_state))) + + progress = float(status.get("progress", 0)) # Don't mark complete while files are being moved - complete = progress >= 100 and deluge_state != 'Moving' + complete = progress >= 100 and deluge_state != "Moving" if complete: message = "Complete" - eta = status.get(b'eta') - if eta and eta > 604800: + eta = status.get("eta") + if eta is not None: + try: + eta = int(eta) + except Exception: + eta = None + + if eta is not None and (eta < 0 or eta > 604800): eta = None file_path = None if complete: - save_path = _decode(status.get(b'save_path', b'')) - name = _decode(status.get(b'name', b'')) - if save_path and name: - file_path = f"{save_path}/{name}" + file_path = self._build_path( + str(status.get("save_path", "")), + str(status.get("name", "")), + ) return DownloadStatus( progress=progress, @@ -207,36 +299,18 @@ class DelugeClient(DownloadClient): message=message, complete=complete, file_path=file_path, - download_speed=status.get(b'download_payload_rate'), + download_speed=status.get("download_payload_rate"), eta=eta, ) except Exception as e: - self._connected = False - error_type = type(e).__name__ - logger.error(f"Deluge get_status failed ({error_type}): {e}") - return DownloadStatus.error(f"{error_type}: {e}") + return DownloadStatus.error(self._log_error("get_status", e)) def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a torrent from Deluge. - - Args: - download_id: Torrent info_hash - delete_files: Whether to also delete files - - Returns: - True if successful. - """ try: self._ensure_connected() - result = self._client.call( - 'core.remove_torrent', - download_id, - delete_files, - ) - + result = self._rpc_call("core.remove_torrent", download_id, delete_files) if result: logger.info( f"Removed torrent from Deluge: {download_id}" @@ -246,45 +320,31 @@ class DelugeClient(DownloadClient): return False except Exception as e: - self._connected = False - error_type = type(e).__name__ - logger.error(f"Deluge remove failed ({error_type}): {e}") + self._log_error("remove", e) return False def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where torrent files are located. - - Args: - download_id: Torrent info_hash - - Returns: - Content path (file or directory), or None. - """ try: self._ensure_connected() - status = self._client.call( - 'core.get_torrent_status', + status = self._rpc_call( + "core.get_torrent_status", download_id, - ['save_path', 'name'], + ["save_path", "name"], ) if status: - save_path = _decode(status.get(b'save_path', b'')) - name = _decode(status.get(b'name', b'')) - if save_path and name: - return f"{save_path}/{name}" + return self._build_path( + str(status.get("save_path", "")), + str(status.get("name", "")), + ) return None except Exception as e: - self._connected = False - error_type = type(e).__name__ - logger.debug(f"Deluge get_download_path failed ({error_type}): {e}") + self._log_error("get_download_path", e, level="debug") return None def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: - """Check if a torrent for this URL already exists in Deluge.""" try: self._ensure_connected() @@ -292,10 +352,10 @@ class DelugeClient(DownloadClient): if not torrent_info.info_hash: return None - status = self._client.call( - 'core.get_torrent_status', + status = self._rpc_call( + "core.get_torrent_status", torrent_info.info_hash, - ['state'], + ["state"], ) if status: @@ -303,7 +363,9 @@ class DelugeClient(DownloadClient): return (torrent_info.info_hash, full_status) return None + except Exception as e: + self._authenticated = False self._connected = False logger.debug(f"Error checking for existing torrent: {e}") return None diff --git a/shelfmark/release_sources/prowlarr/clients/nzbget.py b/shelfmark/release_sources/prowlarr/clients/nzbget.py index 72b84f5..51733b9 100644 --- a/shelfmark/release_sources/prowlarr/clients/nzbget.py +++ b/shelfmark/release_sources/prowlarr/clients/nzbget.py @@ -15,6 +15,7 @@ from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, register_client, + with_retry, ) logger = setup_logger(__name__) @@ -45,6 +46,7 @@ class NZBGetClient(DownloadClient): url = config.get("NZBGET_URL", "") return client == "nzbget" and bool(url) + @with_retry() def _rpc_call(self, method: str, params: list = None) -> Any: """ Make a JSON-RPC call to NZBGet. @@ -57,7 +59,7 @@ class NZBGetClient(DownloadClient): Result from NZBGet. Raises: - Exception: If RPC call fails. + Exception: If RPC call fails after retries. """ rpc_url = f"{self.url}/jsonrpc" @@ -96,7 +98,7 @@ class NZBGetClient(DownloadClient): except Exception as e: return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: """ Add NZB by URL. @@ -226,7 +228,10 @@ class NZBGetClient(DownloadClient): for item in history: if item.get("NZBID") == nzb_id: status = item.get("Status", "") - dest_dir = item.get("DestDir", "") + # Prefer FinalDir (post-processing result) over DestDir (original) + final_dir = item.get("FinalDir", "") or None + dest_dir = item.get("DestDir", "") or None + file_path = final_dir or dest_dir # Use FinalDir if available if "SUCCESS" in status: return DownloadStatus( @@ -234,7 +239,7 @@ class NZBGetClient(DownloadClient): state="complete", message="Complete", complete=True, - file_path=dest_dir, + file_path=file_path, ) else: return DownloadStatus( @@ -248,35 +253,48 @@ class NZBGetClient(DownloadClient): # Not found in queue or history return DownloadStatus.error("Download not found") except Exception as e: - error_type = type(e).__name__ - logger.error(f"NZBGet get_status failed ({error_type}): {e}") - return DownloadStatus.error(f"{error_type}: {e}") + return DownloadStatus.error(self._log_error("get_status", e)) def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a download from NZBGet. + """Remove a download from NZBGet. + + NZBGet can remove items from either the active queue (Group* commands) or from + history (History* commands). Completed downloads are typically in history. Args: download_id: NZBGet NZBID - delete_files: Whether to permanently delete (vs move to history) + delete_files: Whether to permanently delete downloaded files Returns: True if successful. """ try: nzb_id = int(download_id) - # editqueue params: Command (str), Param (str), IDs (int[]) - # GroupFinalDelete = permanent removal, GroupDelete = move to history - command = "GroupFinalDelete" if delete_files else "GroupDelete" - result = self._rpc_call("editqueue", [command, "", [nzb_id]]) - if result: - logger.info(f"Removed NZB from NZBGet: {download_id}") - return bool(result) - except Exception as e: - error_type = type(e).__name__ - logger.error(f"NZBGet remove failed ({error_type}): {e}") + except (TypeError, ValueError) as e: + self._log_error("remove", e) return False + if delete_files: + # Sonarr uses HistoryDelete for NZBGet; keep that as a fallback for + # older NZBGet versions where HistoryFinalDelete may not exist. + commands = ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"] + else: + commands = ["GroupDelete", "HistoryDelete"] + + last_error: Optional[Exception] = None + for command in commands: + try: + result = self._rpc_call("editqueue", [command, 0, "", nzb_id]) + if result: + logger.info(f"Removed NZB from NZBGet ({command}): {download_id}") + return True + except Exception as e: + last_error = e + + if last_error is not None: + self._log_error("remove", last_error) + return False + def get_download_path(self, download_id: str) -> Optional[str]: """ Get the path where NZB files are located. diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py index c315056..e2e1a25 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py @@ -100,7 +100,7 @@ class QBittorrentClient(DownloadClient): except Exception as e: return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: + def add_download(self, url: str, name: str, category: str | None = None) -> str: """ Add torrent by URL (magnet or .torrent). @@ -230,10 +230,10 @@ class QBittorrentClient(DownloadClient): file_path = torrent.content_path else: # Fallback for Amarr which doesn't populate content_path - save_path = getattr(torrent, 'save_path', '') - name = getattr(torrent, 'name', '') - if save_path and name: - file_path = f"{save_path}/{name}" + file_path = self._build_path( + getattr(torrent, 'save_path', ''), + getattr(torrent, 'name', ''), + ) return DownloadStatus( progress=torrent.progress * 100, @@ -245,9 +245,7 @@ class QBittorrentClient(DownloadClient): eta=eta, ) except Exception as e: - error_type = type(e).__name__ - logger.error(f"qBittorrent get_status failed ({error_type}): {e}") - return DownloadStatus.error(f"{error_type}: {e}") + return DownloadStatus.error(self._log_error("get_status", e)) def remove(self, download_id: str, delete_files: bool = False) -> bool: """ @@ -270,8 +268,7 @@ class QBittorrentClient(DownloadClient): ) return True except Exception as e: - error_type = type(e).__name__ - logger.error(f"qBittorrent remove failed ({error_type}): {e}") + self._log_error("remove", e) return False def get_download_path(self, download_id: str) -> Optional[str]: @@ -284,12 +281,12 @@ class QBittorrentClient(DownloadClient): # Prefer content_path, fall back to save_path/name (for Amarr compatibility) if getattr(torrent, 'content_path', ''): return torrent.content_path - save_path = getattr(torrent, 'save_path', '') - name = getattr(torrent, 'name', '') - return f"{save_path}/{name}" if save_path and name else None + return self._build_path( + getattr(torrent, 'save_path', ''), + getattr(torrent, 'name', ''), + ) except Exception as e: - error_type = type(e).__name__ - logger.debug(f"qBittorrent get_download_path failed ({error_type}): {e}") + self._log_error("get_download_path", e, level="debug") return None def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py index 5d9a8a5..e046a28 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py @@ -14,6 +14,7 @@ from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, register_client, + with_retry, ) logger = setup_logger(__name__) @@ -91,6 +92,7 @@ class SABnzbdClient(DownloadClient): api_key = config.get("SABNZBD_API_KEY", "") return client == "sabnzbd" and bool(url) and bool(api_key) + @with_retry() def _api_call(self, mode: str, params: dict = None) -> Any: """ Make an API call to SABnzbd. @@ -103,7 +105,7 @@ class SABnzbdClient(DownloadClient): JSON response from SABnzbd. Raises: - Exception: If API call fails. + Exception: If API call fails after retries. """ api_url = f"{self.url}/api" @@ -241,6 +243,8 @@ class SABnzbdClient(DownloadClient): if slot.get("nzo_id") == download_id: status_text = slot.get("status", "").upper() storage = slot.get("storage", "") + if storage is None: + storage = "" logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'") if status_text == "COMPLETED": @@ -276,9 +280,7 @@ class SABnzbdClient(DownloadClient): logger.warning(f"SABnzbd: download {download_id} not found in queue or history") return DownloadStatus.error("Download not found") except Exception as e: - error_type = type(e).__name__ - logger.error(f"SABnzbd get_status failed ({error_type}): {e}") - return DownloadStatus.error(f"{error_type}: {e}") + return DownloadStatus.error(self._log_error("get_status", e)) def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool: """ @@ -325,8 +327,7 @@ class SABnzbdClient(DownloadClient): return False except Exception as e: - error_type = type(e).__name__ - logger.error(f"SABnzbd remove failed ({error_type}): {e}") + self._log_error("remove", e) return False def get_download_path(self, download_id: str) -> Optional[str]: diff --git a/shelfmark/release_sources/prowlarr/clients/transmission.py b/shelfmark/release_sources/prowlarr/clients/transmission.py index 8b7ad1e..a7dc2be 100644 --- a/shelfmark/release_sources/prowlarr/clients/transmission.py +++ b/shelfmark/release_sources/prowlarr/clients/transmission.py @@ -163,9 +163,10 @@ class TransmissionClient(DownloadClient): # Get file path for completed downloads file_path = None if complete: - download_dir = torrent.download_dir - name = torrent.name - file_path = f"{download_dir}/{name}" + file_path = self._build_path( + getattr(torrent, 'download_dir', ''), + getattr(torrent, 'name', ''), + ) return DownloadStatus( progress=progress, @@ -180,9 +181,7 @@ class TransmissionClient(DownloadClient): except KeyError: return DownloadStatus.error("Torrent not found") except Exception as e: - error_type = type(e).__name__ - logger.error(f"Transmission get_status failed ({error_type}): {e}") - return DownloadStatus.error(f"{error_type}: {e}") + return DownloadStatus.error(self._log_error("get_status", e)) def remove(self, download_id: str, delete_files: bool = False) -> bool: """ @@ -206,8 +205,7 @@ class TransmissionClient(DownloadClient): ) return True except Exception as e: - error_type = type(e).__name__ - logger.error(f"Transmission remove failed ({error_type}): {e}") + self._log_error("remove", e) return False def get_download_path(self, download_id: str) -> Optional[str]: @@ -222,12 +220,12 @@ class TransmissionClient(DownloadClient): """ try: torrent = self._client.get_torrent(download_id) - download_dir = torrent.download_dir - name = torrent.name - return f"{download_dir}/{name}" + return self._build_path( + getattr(torrent, 'download_dir', ''), + getattr(torrent, 'name', ''), + ) except Exception as e: - error_type = type(e).__name__ - logger.debug(f"Transmission get_download_path failed ({error_type}): {e}") + self._log_error("get_download_path", e, level="debug") return None def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index b56ee1d..85bedec 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,6 +1,5 @@ """Prowlarr download handler - executes downloads via torrent/usenet clients.""" -import shutil from pathlib import Path from threading import Event from typing import Callable, Optional @@ -12,11 +11,12 @@ from shelfmark.core.utils import is_audiobook from shelfmark.release_sources import DownloadHandler, register_handler from shelfmark.release_sources.prowlarr.cache import get_release, remove_release from shelfmark.release_sources.prowlarr.clients import ( + DownloadClient, DownloadState, get_client, list_configured_clients, ) -from shelfmark.release_sources.prowlarr.utils import get_protocol, get_unique_path +from shelfmark.release_sources.prowlarr.utils import get_protocol logger = setup_logger(__name__) @@ -24,10 +24,48 @@ logger = setup_logger(__name__) POLL_INTERVAL = 2 +def _diagnose_path_issue(path: str) -> str: + """ + Analyze a path and return diagnostic hints for common issues. + + Args: + path: The path that failed to be accessed + + Returns: + A hint string to help users diagnose the issue. + """ + # Detect Windows-style paths (won't work in Linux containers) + if len(path) >= 2 and path[1] == ':': + return ( + f"Path '{path}' appears to be a Windows path. " + f"Shelfmark runs in Linux and cannot access Windows paths directly. " + f"Ensure your download client uses Linux-style paths (/path/to/files)." + ) + + # Detect backslashes (Windows path separators) + if '\\' in path: + return ( + f"Path '{path}' contains backslashes. " + f"This may indicate a Windows path or incorrect path escaping. " + f"Linux paths should use forward slashes (/)." + ) + + # Generic hint for Linux paths + return ( + f"Path '{path}' is not accessible from Shelfmark's container. " + f"Ensure both containers have matching volume mounts for this directory." + ) + + @register_handler("prowlarr") class ProwlarrHandler(DownloadHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" + def __init__(self): + # Track downloads that may need client-side cleanup after Shelfmark completes import. + # task_id -> (client, download_id, protocol) + self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {} + def _get_category_for_task(self, client, task: DownloadTask) -> Optional[str]: """Get audiobook category if configured and applicable, else None for default.""" if not is_audiobook(task.content_type): @@ -44,13 +82,52 @@ class ProwlarrHandler(DownloadHandler): audiobook_key = audiobook_keys.get(client.name) return config.get(audiobook_key, "") or None if audiobook_key else None - def _cleanup_client_history(self, client, download_id: str) -> None: - """Remove completed download from client history if configured.""" - if client.name == "sabnzbd" and config.get("SABNZBD_REMOVE_COMPLETED", True): - try: - client.remove(download_id, delete_files=True, archive=True) - except Exception as e: - logger.warning(f"Failed to remove from SABnzbd history: {e}") + def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: + if not success: + self._cleanup_refs.pop(task.task_id, None) + return + + client_ref = self._cleanup_refs.pop(task.task_id, None) + if client_ref is None: + 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 + + try: + client.remove(download_id, delete_files=True) + except Exception as e: + logger.warning(f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}") + + def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None: + """Best-effort removal of a failed/cancelled download from the client. + + Safety policy: + - torrents: never remove or delete client data (avoid breaking seeding) + - usenet: keep legacy behavior (delete client files on removal) + """ + + if protocol != "usenet": + logger.info( + "Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)", + protocol, + reason, + getattr(client, "name", "client"), + download_id, + ) + return + + try: + client.remove(download_id, delete_files=True) + except Exception as e: + logger.warning( + f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" + ) def _build_progress_message(self, status) -> str: """Build a progress message from download status.""" @@ -123,7 +200,16 @@ class ProwlarrHandler(DownloadHandler): source_path = client.get_download_path(download_id) if not source_path: - status_callback("error", "Could not locate existing download file") + logger.error( + f"Could not get path for existing download. " + f"Client: {client.name}, ID: {download_id}. " + f"The download may have been moved or deleted." + ) + status_callback( + "error", + f"Could not locate existing download in {client.name}. " + f"Check that the file still exists." + ) return None result = self._handle_completed_file( @@ -135,7 +221,7 @@ class ProwlarrHandler(DownloadHandler): if result: remove_release(task.task_id) - self._cleanup_client_history(client, download_id) + self._cleanup_refs[task.task_id] = (client, download_id, protocol) return result # Existing but still downloading - join the progress polling @@ -186,6 +272,10 @@ class ProwlarrHandler(DownloadHandler): status_callback: Callable[[str, Optional[str]], None], ) -> Optional[str]: """Poll the download client for progress and handle completion.""" + # Track consecutive "not found" errors - torrents may take time to appear in client + not_found_count = 0 + max_not_found_retries = 15 # 15 retries * 2s poll = 30s grace period + try: logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})") while not cancel_flag.is_set(): @@ -197,6 +287,7 @@ class ProwlarrHandler(DownloadHandler): if status.state == DownloadState.ERROR: logger.error(f"Download {download_id} completed with error: {status.message}") status_callback("error", status.message or "Download failed") + self._safe_remove_download(client, download_id, protocol, "completion error") return None # Download complete - break to handle file logger.debug(f"Download {download_id} complete, file_path={status.file_path}") @@ -204,11 +295,31 @@ class ProwlarrHandler(DownloadHandler): # Check for error state if status.state == DownloadState.ERROR: - logger.error(f"Download {download_id} error state: {status.message}") + # "Torrent not found" is often transient - the client may not have indexed it yet + if "not found" in (status.message or "").lower(): + not_found_count += 1 + if not_found_count < max_not_found_retries: + logger.debug( + f"Download {download_id} not yet visible in client " + f"(attempt {not_found_count}/{max_not_found_retries})" + ) + status_callback("resolving", "Waiting for download client...") + if cancel_flag.wait(timeout=POLL_INTERVAL): + break + continue + # Exhausted retries + logger.error( + f"Download {download_id} not found after {max_not_found_retries} attempts" + ) + else: + logger.error(f"Download {download_id} error state: {status.message}") status_callback("error", status.message or "Download failed") - client.remove(download_id, delete_files=True) + self._safe_remove_download(client, download_id, protocol, "download error") return None + # Reset not-found counter on successful status check + not_found_count = 0 + # Build status message - use client message if provided, else build progress msg = status.message or self._build_progress_message(status) if status.state == DownloadState.PROCESSING: @@ -223,8 +334,18 @@ class ProwlarrHandler(DownloadHandler): # Handle cancellation if cancel_flag.is_set(): - logger.info(f"Download cancelled, removing from {client.name}: {download_id}") - client.remove(download_id, delete_files=True) + if protocol == "usenet": + logger.info(f"Download cancelled, removing from {client.name}: {download_id}") + try: + client.remove(download_id, delete_files=True) + except Exception as e: + logger.warning( + f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" + ) + else: + logger.info( + f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" + ) status_callback("cancelled", "Cancelled") return None @@ -238,7 +359,7 @@ class ProwlarrHandler(DownloadHandler): ) status_callback( "error", - f"Download completed in {client.name} but path not returned. " + f"Could not locate completed download in {client.name} (path not returned). " f"Check volume mappings and category settings." ) return None @@ -246,16 +367,12 @@ class ProwlarrHandler(DownloadHandler): # Verify the path actually exists in our filesystem source_path_obj = Path(source_path) if not source_path_obj.exists(): + hint = _diagnose_path_issue(source_path) logger.error( f"Download path does not exist: {source_path}. " - f"Client: {client.name}, ID: {download_id}. " - f"The download client's path may not be mounted in Shelfmark's container. " - f"Ensure both containers use identical volume mappings for the download folder." - ) - status_callback( - "error", - f"Path not accessible: {source_path}. Check volume mappings between {client.name} and Shelfmark." + f"Client: {client.name}, ID: {download_id}. {hint}" ) + status_callback("error", hint) return None result = self._handle_completed_file( @@ -268,17 +385,14 @@ class ProwlarrHandler(DownloadHandler): # Clean up on success if result: remove_release(task.task_id) - self._cleanup_client_history(client, download_id) + self._cleanup_refs[task.task_id] = (client, download_id, protocol) return result except Exception as e: logger.error(f"Error during download polling: {e}") status_callback("error", str(e)) - try: - client.remove(download_id, delete_files=True) - except Exception as cleanup_error: - logger.error(f"Failed to cleanup download {download_id} after error: {cleanup_error}") + self._safe_remove_download(client, download_id, protocol, "polling exception") return None def _handle_completed_file( @@ -288,56 +402,26 @@ class ProwlarrHandler(DownloadHandler): task: DownloadTask, status_callback: Callable[[str, Optional[str]], None], ) -> Optional[str]: - """Handle completed download. Torrents return original path; usenet stages to temp.""" + """Handle a completed download and return its path. + + For external download clients (torrents/usenet), staging large payloads into TMP_DIR + is expensive (and can duplicate multi-GB files). Instead, return the client's + completed path and let the orchestrator perform any required transfer (copy/move/ + hardlink) directly from that source. + + Torrents also set ``task.original_download_path`` so the orchestrator can detect + seeding data and enable hardlinking when configured. + """ try: - # For torrents, skip staging - return original path directly - # Orchestrator will hardlink (library mode) or copy (ingest mode) as needed if protocol == "torrent": task.original_download_path = str(source_path) - logger.debug(f"Torrent complete, returning original path: {source_path}") - return str(source_path) - # Usenet: stage based on config - status_callback("resolving", "Staging file") - use_copy = config.get("PROWLARR_USENET_ACTION", "move") == "copy" + logger.debug(f"Download complete, returning original path: {source_path}") + return str(source_path) - from shelfmark.download.orchestrator import get_staging_dir - staging_dir = get_staging_dir() - - if source_path.is_dir(): - staged_path = get_unique_path(staging_dir, source_path.name) - if use_copy: - shutil.copytree(str(source_path), str(staged_path)) - else: - shutil.move(str(source_path), str(staged_path)) - logger.debug(f"Staged directory: {staged_path.name}") - else: - staged_path = get_unique_path(staging_dir, source_path.stem, source_path.suffix) - if use_copy: - shutil.copy2(str(source_path), str(staged_path)) - else: - shutil.move(str(source_path), str(staged_path)) - logger.debug(f"Staged: {staged_path.name}") - - return str(staged_path) - - except FileNotFoundError as e: - logger.error( - f"Source file not found during staging: {source_path}. " - f"The file may have been moved or deleted by the download client. Error: {e}" - ) - status_callback("error", f"File not found: {source_path}. It may have been moved or deleted.") - return None - except PermissionError as e: - logger.error( - f"Permission denied staging file from {source_path}. " - f"Check that Shelfmark has read access to the download folder. Error: {e}" - ) - status_callback("error", f"Permission denied accessing {source_path}. Check folder permissions.") - return None except Exception as e: - logger.error(f"Staging failed for {source_path}: {e}") - status_callback("error", f"Failed to stage file: {e}") + logger.error(f"Failed to finalize completed download at {source_path}: {e}") + status_callback("error", f"Failed to finalize completed download: {e}") return None def cancel(self, task_id: str) -> bool: diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 11e2325..977bc3e 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -162,35 +162,96 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: - """Test the Deluge connection using current form values.""" + """Test Deluge Web UI JSON-RPC connection using current form values.""" + from urllib.parse import urlparse + + import requests from shelfmark.core.config import config current_values = current_values or {} - host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost") - port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "58846") - username = current_values.get("DELUGE_USERNAME") or config.get("DELUGE_USERNAME", "") + raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost") + raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112") password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "") - if not host: + if not raw_host: return {"success": False, "message": "Deluge host is required"} if not password: return {"success": False, "message": "Deluge password is required"} - try: - from deluge_client import DelugeRPCClient + raw_host = str(raw_host) + raw_port = str(raw_port or "8112") - client = DelugeRPCClient( - host=host, - port=int(port), - username=username, - password=password, - ) - client.connect() - version = client.call('daemon.info') + scheme = "http" + base_path = "" + host = raw_host + port = int(raw_port) if raw_port.isdigit() else 8112 + + # Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112) + if raw_host.startswith(("http://", "https://")): + parsed = urlparse(raw_host) + scheme = parsed.scheme or "http" + host = parsed.hostname or "localhost" + if parsed.port is not None: + port = parsed.port + base_path = (parsed.path or "").rstrip("/") + else: + # Allow "host:port" in DELUGE_HOST for convenience. + if ":" in raw_host and raw_host.count(":") == 1: + host_part, port_part = raw_host.split(":", 1) + if host_part and port_part.isdigit(): + host = host_part + port = int(port_part) + + rpc_url = f"{scheme}://{host}:{port}{base_path}/json" + + def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any: + payload = {"id": rpc_id, "method": method, "params": list(params)} + resp = session.post(rpc_url, json=payload, timeout=15) + resp.raise_for_status() + data = resp.json() + if data.get("error"): + error = data["error"] + if isinstance(error, dict): + raise Exception(error.get("message") or str(error)) + raise Exception(str(error)) + return data.get("result") + + try: + session = requests.Session() + + if rpc_call(session, 1, "auth.login", password) is not True: + return {"success": False, "message": "Deluge Web UI authentication failed"} + + if rpc_call(session, 2, "web.connected") is not True: + hosts = rpc_call(session, 3, "web.get_hosts") or [] + if not hosts: + return { + "success": False, + "message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.", + } + + host_id = hosts[0][0] + for entry in hosts: + if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: + host_id = entry[0] + break + + rpc_call(session, 4, "web.connect", host_id) + + if rpc_call(session, 5, "web.connected") is not True: + return { + "success": False, + "message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.", + } + + version = rpc_call(session, 6, "daemon.info") return {"success": True, "message": f"Connected to Deluge {version}"} - except ImportError: - return {"success": False, "message": "deluge-client package not installed"} + + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to Deluge Web UI"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} except Exception as e: return {"success": False, "message": f"Connection failed: {str(e)}"} @@ -486,30 +547,24 @@ def prowlarr_clients_settings(): # --- Deluge Settings --- TextField( key="DELUGE_HOST", - label="Deluge Host", - description="Hostname or IP of your Deluge daemon", - placeholder="localhost", + label="Deluge Web UI Host/URL", + description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)", + placeholder="http://deluge:8112", default="localhost", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, ), TextField( key="DELUGE_PORT", - label="Deluge Port", - description="Deluge daemon RPC port (default: 58846). IMPORTANT: Ensure \"Allow Remote Connections\" is enabled in Deluge settings.", - placeholder="58846", - default="58846", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - TextField( - key="DELUGE_USERNAME", - label="Username", - description="Deluge daemon username (from auth file)", + label="Deluge Web UI Port", + description="Deluge Web UI port (default: 8112)", + placeholder="8112", + default="8112", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, ), PasswordField( key="DELUGE_PASSWORD", label="Password", - description="Deluge daemon password (from auth file)", + description="Deluge Web UI password (default: deluge)", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, ), ActionButton( @@ -686,22 +741,14 @@ def prowlarr_clients_settings(): default="", show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, ), - CheckboxField( - key="SABNZBD_REMOVE_COMPLETED", - label="Remove completed downloads from history", - default=True, - description="Remove downloads from SABnzbd history after successful import (archives them)", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - # Note: Usenet client download path must be mounted identically in both containers. SelectField( key="PROWLARR_USENET_ACTION", label="NZB Completion Action", - description="What to do with usenet files after download completes", + description="Copy files into your ingest folder, optionally cleaning up the usenet client", options=[ - {"value": "move", "label": "Move to ingest"}, - {"value": "copy", "label": "Copy to ingest"}, + {"value": "move", "label": "Copy and remove from client"}, + {"value": "copy", "label": "Copy (keep in client)"}, ], default="move", show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 8e172e7..fdb02ec 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -5,7 +5,7 @@ from typing import List, Optional from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.metadata_providers import BookMetadata +from shelfmark.metadata_providers import BookMetadata, build_localized_search_titles from shelfmark.release_sources import ( Release, ReleaseSource, @@ -304,12 +304,8 @@ class ProwlarrSource(ReleaseSource): logger.warning("Prowlarr not configured - skipping search") return [] - # Build search query - query_parts = [] - # Prefer search_title if available (cleaner title for searches) - search_title = book.search_title or book.title - if search_title: - query_parts.append(search_title) + # Build search queries (optionally include localized titles) + query_author = "" if book.authors: # Use first author only - authors may be a list or a single string # that contains multiple comma-separated names (from frontend) @@ -317,14 +313,34 @@ class ProwlarrSource(ReleaseSource): # If first author contains comma, split and use only the primary author if "," in first_author: first_author = first_author.split(",")[0].strip() - query_parts.append(first_author) + query_author = first_author - query = " ".join(query_parts) - if not query: + # Prefer search_title if available (cleaner title for searches) + search_title = book.search_title or book.title + + language_preferences = languages or ([book.language] if book.language else None) + search_titles = build_localized_search_titles( + base_title=search_title, + languages=language_preferences, + titles_by_language=book.titles_by_language, + # Keep the existing search_title behavior for English while still + # allowing additional localized searches for other languages. + excluded_languages={"en", "eng", "english"}, + ) + + queries = [ + " ".join(part for part in [title, query_author] if part).strip() + for title in search_titles + ] + queries = [q for q in queries if q] + + if not queries: # Try ISBN as fallback - query = book.isbn_13 or book.isbn_10 or "" + isbn_query = book.isbn_13 or book.isbn_10 or "" + if isbn_query: + queries = [isbn_query] - if not query: + if not queries: logger.warning("No search query available for book") return [] @@ -338,9 +354,12 @@ class ProwlarrSource(ReleaseSource): self.last_search_type = "expanded" if expand_search else "categories" indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers" - logger.debug(f"Searching Prowlarr: query='{query}', {indexer_desc}, categories={categories}") + if len(queries) == 1: + logger.debug(f"Searching Prowlarr: query='{queries[0]}', {indexer_desc}, categories={categories}") + else: + logger.debug(f"Searching Prowlarr: {len(queries)} queries, {indexer_desc}, categories={categories}") - def search_indexers(cats: Optional[List[int]]) -> List[dict]: + def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]: """Search indexers with given categories, collecting results.""" results = [] if indexer_ids: @@ -362,16 +381,36 @@ class ProwlarrSource(ReleaseSource): logger.warning(f"Search failed for all indexers: {e}") return results - all_results = [] try: - all_results = search_indexers(categories) - - # Auto-expand: if no results with categories and auto-expand enabled, retry without auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False) - if not all_results and categories and auto_expand_enabled: - logger.info("Prowlarr: no results with category filter, auto-expanding search") - all_results = search_indexers(None) - self.last_search_type = "expanded" + + seen_keys: set[str] = set() + all_results: List[dict] = [] + + for idx, query in enumerate(queries, start=1): + if len(queries) > 1: + logger.debug(f"Prowlarr query {idx}/{len(queries)}: '{query}'") + + raw_results = search_indexers(query=query, cats=categories) + + # Auto-expand: if no results with categories and auto-expand enabled, retry without + if not raw_results and categories and auto_expand_enabled: + logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search") + raw_results = search_indexers(query=query, cats=None) + self.last_search_type = "expanded" + + for r in raw_results: + key = ( + r.get("guid") + or r.get("downloadUrl") + or r.get("magnetUrl") + or r.get("infoUrl") + or f"{r.get('indexerId')}:{r.get('title')}" + ) + if key in seen_keys: + continue + seen_keys.add(key) + all_results.append(r) results = [_prowlarr_result_to_release(r, content_type) for r in all_results] diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b16a564..11e98b1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -437,6 +437,7 @@ function App() { await fetchStatus(); } catch (error) { console.error('Cancel failed:', error); + showToast('Failed to cancel/clear download', 'error'); } }; @@ -447,6 +448,7 @@ function App() { await fetchStatus(); } catch (error) { console.error('Clear completed failed:', error); + showToast('Failed to clear finished downloads', 'error'); } }; diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx index 0521ca6..edce660 100644 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ b/src/frontend/src/components/DownloadsSidebar.tsx @@ -142,6 +142,8 @@ export const DownloadsSidebar = ({ }; const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName); + const isQueued = statusName === 'queued'; + const isActive = statusName === 'resolving' || statusName === 'downloading'; const isCompleted = statusName === 'complete'; const hasError = statusName === 'error'; @@ -169,25 +171,27 @@ export const DownloadsSidebar = ({ className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden" style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }} > - {/* Cancel/Clear Button - top right corner */} + {/* Action Button - top right corner */}