Fix: Add SABnzbd cleanup, file directory notice, audiobook button (#444)

- Add SABnzbd archive cleanup upon download completion
- Added frontend audiobook library URL button
- Clearer errors for misconfigured destination paths
- Test clients.yml added
This commit is contained in:
Alex
2026-01-13 19:06:17 +00:00
committed by GitHub
parent be5382cd1e
commit 8b801c104e
10 changed files with 230 additions and 10 deletions
+161
View File
@@ -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
+9 -2
View File
@@ -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",
+8 -1
View File
@@ -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
+1
View File
@@ -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,
@@ -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
+11 -1
View File
@@ -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
@@ -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(
+1
View File
@@ -541,6 +541,7 @@ function App() {
<SearchModeProvider searchMode={searchMode}>
<Header
calibreWebUrl={config?.calibre_web_url || ''}
audiobookLibraryUrl={config?.audiobook_library_url || ''}
debug={config?.debug || false}
logoUrl="/logo.png"
showSearch={!isInitialState}
+26 -4
View File
@@ -14,6 +14,7 @@ interface StatusCounts {
interface HeaderProps {
calibreWebUrl?: string;
audiobookLibraryUrl?: string;
debug?: boolean;
logoUrl?: string;
showSearch?: boolean;
@@ -37,6 +38,7 @@ interface HeaderProps {
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
calibreWebUrl,
audiobookLibraryUrl,
debug,
logoUrl,
showSearch = false,
@@ -157,23 +159,43 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
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 = () => (
<div className="flex items-center gap-2">
{/* Calibre-Web Button */}
{/* Book Library Button */}
{calibreWebUrl && (
<a
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="Open Calibre-Web"
title="Go To Library"
aria-label="Open book library"
title={showIconsOnly ? "Book Library" : "Go To Library"}
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span className="text-sm font-medium">Go To Library</span>
{!showIconsOnly && <span className="text-sm font-medium">Go To Library</span>}
</a>
)}
{/* Audiobook Library Button */}
{audiobookLibraryUrl && (
<a
href={audiobookLibraryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="Open audiobook library"
title={showIconsOnly ? "Audiobook Library" : "Go To Library"}
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
{!showIconsOnly && <span className="text-sm font-medium">Go To Library</span>}
</a>
)}
+1
View File
@@ -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;