diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml new file mode 100644 index 0000000..874dbee --- /dev/null +++ b/docker-compose.test-clients.yml @@ -0,0 +1,161 @@ +# Test stack for download client development +# Includes shelfmark + all download clients on same network with shared volumes +# +# Usage: +# docker compose -f docker-compose.test-clients.yml up -d +# # Access shelfmark at http://localhost:8084 +# # Configure clients in Settings > Prowlarr > Download Clients +# +# Web UIs: +# - shelfmark: http://localhost:8084 +# - 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) +# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789) +# - SABnzbd: http://localhost:8085 (complete setup wizard for API key) +# + + +services: + shelfmark: + build: + context: . + dockerfile: Dockerfile + target: shelfmark + container_name: test-shelfmark + cap_add: + - SYS_PTRACE + environment: + TZ: UTC + DEBUG: "true" + # All client configuration is done via Settings UI + # Use Docker service names for URLs: + # - qBittorrent: http://qbittorrent:8080 + # - Transmission: http://transmission:9091 + # - Deluge host: deluge (port 58846) + # - NZBGet: http://nzbget:6789 + # - SABnzbd: http://sabnzbd:8080 + ports: + - "8084:8084" + volumes: + # Config and state + - ./.local/test-clients/shelfmark/config:/config + - ./.local/test-clients/shelfmark/log:/var/log/shelfmark + # Book destination directory (where completed books go) + - ./.local/test-clients/books:/books + # Staging directory + - ./.local/test-clients/tmp:/tmp/shelfmark + # CRITICAL: Mount client download directories so shelfmark can access completed files + - ./.local/test-clients/downloads:/downloads + # Mount source code for hot-reload (no rebuild needed for Python changes) + - ./shelfmark:/app/shelfmark:ro + # Mount tests for running pytest in container + - ./tests:/app/tests:ro + - ./pyproject.toml:/app/pyproject.toml:ro + # Mount client configs for integration tests to read credentials + - ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro + - ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro + depends_on: + - nzbget + - sabnzbd + - qbittorrent + - transmission + - deluge + restart: unless-stopped + + prowlarr: + image: lscr.io/linuxserver/prowlarr:latest + container_name: test-prowlarr + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - ./.local/test-clients/prowlarr/config:/config + ports: + - "9696:9696" + restart: unless-stopped + + nzbget: + image: lscr.io/linuxserver/nzbget:latest + container_name: test-nzbget + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - ./.local/test-clients/nzbget/config:/config + - ./.local/test-clients/downloads:/downloads + - ./.local/test-clients/nzbget/custom-cont-init.d:/custom-cont-init.d:ro + ports: + - "6789:6789" # Web UI / JSON-RPC + restart: unless-stopped + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:latest + container_name: test-sabnzbd + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - ./.local/test-clients/sabnzbd/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "8085:8080" # Web UI (external:internal) + restart: unless-stopped + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: test-qbittorrent + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - WEBUI_PORT=8080 + volumes: + - ./.local/test-clients/qbittorrent/config:/config + - ./.local/test-clients/downloads:/downloads + - ./.local/test-clients/qbittorrent/custom-cont-init.d:/custom-cont-init.d:ro + ports: + - "8080:8080" # Web UI / API + - "6882:6881" + - "6882:6881/udp" + restart: unless-stopped + + transmission: + image: lscr.io/linuxserver/transmission:latest + container_name: test-transmission + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - USER=admin + - PASS=admin + volumes: + - ./.local/test-clients/transmission/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "9091:9091" # Web UI / RPC + - "51413:51413" + - "51413:51413/udp" + restart: unless-stopped + + deluge: + image: lscr.io/linuxserver/deluge:latest + container_name: test-deluge + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - DELUGE_LOGLEVEL=error + volumes: + - ./.local/test-clients/deluge/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "8112:8112" # Web UI + - "58846:58846" # Daemon RPC + - "6881:6881" + - "6881:6881/udp" + restart: unless-stopped diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 99609ee..352696a 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -268,10 +268,17 @@ def general_settings(): return [ TextField( key="CALIBRE_WEB_URL", - label="Book Management App URL", - description="Adds a navigation button to your book manager instance (Calibre-Web Automated, Booklore, etc).", + label="Library URL", + description="Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc).", placeholder="http://calibre-web:8083", ), + TextField( + key="AUDIOBOOK_LIBRARY_URL", + 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", title="Default Search Filters", diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 9265b73..1e512a0 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -552,7 +552,14 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: task = book_queue.get_task(task_id) if task: book_queue.update_status(task_id, QueueStatus.ERROR) - book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}") + # Check for known misconfiguration from earlier versions + if isinstance(e, PermissionError) and "/cwa-book-ingest" in str(e): + book_queue.update_status_message( + task_id, + "Destination misconfigured. Go to Settings → Downloads to update." + ) + else: + book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}") return None diff --git a/shelfmark/main.py b/shelfmark/main.py index 9603a8c..626935f 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -496,6 +496,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), + "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), "debug": app_config.get("DEBUG", False), "build_version": BUILD_VERSION, "release_version": RELEASE_VERSION, diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py index 1a86a33..5d9a8a5 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py @@ -280,13 +280,14 @@ class SABnzbdClient(DownloadClient): logger.error(f"SABnzbd get_status failed ({error_type}): {e}") return DownloadStatus.error(f"{error_type}: {e}") - def remove(self, download_id: str, delete_files: bool = False) -> bool: + def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool: """ Remove a download from SABnzbd. Args: download_id: SABnzbd nzo_id delete_files: Whether to delete the files + archive: If True, move to archive instead of permanent delete (history only) Returns: True if successful. @@ -313,11 +314,13 @@ class SABnzbdClient(DownloadClient): "name": "delete", "value": download_id, "del_files": 1 if delete_files else 0, + "archive": 1 if archive else 0, }, ) if result.get("status"): - logger.info(f"Removed NZB from SABnzbd history: {download_id}") + action = "archived" if archive else "removed" + logger.info(f"NZB {action} from SABnzbd history: {download_id}") return True return False diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index a893b3d..b56ee1d 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -44,6 +44,14 @@ 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 _build_progress_message(self, status) -> str: """Build a progress message from download status.""" msg = f"{status.progress:.0f}%" @@ -127,6 +135,7 @@ class ProwlarrHandler(DownloadHandler): if result: remove_release(task.task_id) + self._cleanup_client_history(client, download_id) return result # Existing but still downloading - join the progress polling @@ -256,9 +265,10 @@ class ProwlarrHandler(DownloadHandler): status_callback=status_callback, ) - # Clean up cache on success + # Clean up on success if result: remove_release(task.task_id) + self._cleanup_client_history(client, download_id) return result diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index f90bfee..ee0a23b 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -613,6 +613,13 @@ 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( diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 665e538..b16a564 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -541,6 +541,7 @@ function App() {
(({ calibreWebUrl, + audiobookLibraryUrl, debug, logoUrl, showSearch = false, @@ -157,23 +159,43 @@ export const Header = forwardRef(({ onSearchChange?.(value); }; + // Determine if we should show icons only (both URLs configured) + const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl); + // Icon buttons component - reused for both states const IconButtons = () => (
- {/* Calibre-Web Button */} + {/* Book Library Button */} {calibreWebUrl && ( - Go To Library + {!showIconsOnly && Go To Library} + + )} + + {/* Audiobook Library Button */} + {audiobookLibraryUrl && ( + + + + + {!showIconsOnly && Go To Library} )} diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 40b569b..0e1527a 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -148,6 +148,7 @@ export type ContentType = 'ebook' | 'audiobook'; export interface AppConfig { calibre_web_url: string; + audiobook_library_url: string; debug: boolean; build_version: string; release_version: string;