Files
shelfmark/shelfmark/core/utils.py
T
Alex e5ccabe1ef Patch: Various additions (#564)
- Added rich Prowlarr search results for whitelisted indexers
- Added torznab query for whitelisted indexers
- Added flags for all Prowlarr indexers
- Added completed external download retry mechanism and "locating" state
- Added client side preference storage of Book/Audiobook search
preference
- Fixed reverse proxy base URL in edge cases
- Added gevent locking for I/O operations, keeps healthcheck alive on
intensive processing operations
- Added M4A supported audiobook option
- Improved file transfer counting and logging with hardlink fallback
warnings
- Fixed proxy auth header for REMOTE_USER scenario
- Dependency tweak for internal bypasser
2026-01-31 12:53:11 +00:00

202 lines
6.2 KiB
Python

"""Shared utility functions for the Shelfmark."""
import base64
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
def normalize_http_url(
url: Optional[str],
*,
default_scheme: str = "http",
strip_trailing_slash: bool = True,
allow_special: tuple[str, ...] = (),
) -> str:
"""Normalize a configured HTTP URL for requests and links."""
if not isinstance(url, str):
return ""
normalized = url.strip()
if not normalized:
return ""
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
normalized.startswith("'") and normalized.endswith("'")
):
normalized = normalized[1:-1].strip()
if not normalized:
return ""
if allow_special:
special_map = {
value.lower(): value
for value in allow_special
if isinstance(value, str)
}
special_match = special_map.get(normalized.lower())
if special_match is not None:
return special_match
if normalized.startswith(("/", "./", "../")):
return normalized
if "://" not in normalized:
scheme = default_scheme.strip().rstrip(":/")
if scheme:
normalized = f"{scheme}://{normalized}"
if strip_trailing_slash:
normalized = normalized.rstrip("/")
return normalized
def normalize_base_path(value: Optional[str]) -> str:
"""Normalize a URL base path for reverse proxy subpath deployments."""
if not isinstance(value, str):
return ""
path = value.strip()
if not path:
return ""
if "://" in path:
parsed = urlparse(path)
path = parsed.path or ""
if not path or path == "/":
return ""
if not path.startswith("/"):
path = "/" + path
return path.rstrip("/")
def is_audiobook(content_type: Optional[str]) -> bool:
"""Check if content type indicates an audiobook."""
return bool(content_type and "audiobook" in content_type.lower())
CONTENT_TYPES = [
"book (fiction)",
"book (non-fiction)",
"book (unknown)",
"magazine",
"comic book",
"audiobook",
"standards document",
"musical score",
"other",
]
# Maps AA content types to their config keys for content-type routing
# Used when AA_CONTENT_TYPE_ROUTING is enabled
_AA_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "AA_CONTENT_TYPE_DIR_FICTION",
"book (non-fiction)": "AA_CONTENT_TYPE_DIR_NON_FICTION",
"book (unknown)": "AA_CONTENT_TYPE_DIR_UNKNOWN",
"magazine": "AA_CONTENT_TYPE_DIR_MAGAZINE",
"comic book": "AA_CONTENT_TYPE_DIR_COMIC",
"audiobook": "AA_CONTENT_TYPE_DIR_AUDIOBOOK",
"standards document": "AA_CONTENT_TYPE_DIR_STANDARDS",
"musical score": "AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"other": "AA_CONTENT_TYPE_DIR_OTHER",
}
# Legacy mapping - kept for backwards compatibility during migration
_LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "INGEST_DIR_BOOK_FICTION",
"book (non-fiction)": "INGEST_DIR_BOOK_NON_FICTION",
"book (unknown)": "INGEST_DIR_BOOK_UNKNOWN",
"magazine": "INGEST_DIR_MAGAZINE",
"comic book": "INGEST_DIR_COMIC_BOOK",
"audiobook": "INGEST_DIR_AUDIOBOOK",
"standards document": "INGEST_DIR_STANDARDS_DOCUMENT",
"musical score": "INGEST_DIR_MUSICAL_SCORE",
"other": "INGEST_DIR_OTHER",
}
def get_destination(is_audiobook: bool = False) -> Path:
"""Get base destination directory. Audiobooks fall back to main destination."""
from shelfmark.core.config import config
if is_audiobook:
# Audiobook destination with fallback to main destination
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
if audiobook_dest:
return Path(audiobook_dest)
# Main destination (also fallback for audiobooks)
# Check new setting first, then legacy INGEST_DIR
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books")
return Path(destination)
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
"""Get override directory for AA content-type routing if configured."""
from shelfmark.core.config import config
# Check if content-type routing is enabled (new or legacy setting)
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get("USE_CONTENT_TYPE_DIRECTORIES", False):
return None
if not content_type:
return None
content_type_lower = content_type.lower().strip()
# Try new AA-specific config keys first, then legacy keys
for mapping in (_AA_CONTENT_TYPE_TO_CONFIG_KEY, _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY):
config_key = mapping.get(content_type_lower)
if config_key:
custom_dir = config.get(config_key, "")
if custom_dir:
return Path(custom_dir)
return None
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
"""DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead."""
from shelfmark.core.config import config
# Check new DESTINATION setting first, then legacy INGEST_DIR
default_ingest_dir = Path(config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books"))
if not content_type:
return default_ingest_dir
# Check for content-type override
override_dir = get_aa_content_type_dir(content_type)
if override_dir:
return override_dir
return default_ingest_dir
def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str]:
"""Transform external cover URL to local proxy URL when caching is enabled."""
if not cover_url:
return cover_url
# Skip if already a local URL (starts with /)
if cover_url.startswith('/'):
return cover_url
# Check if cover caching is enabled
from shelfmark.config.env import is_covers_cache_enabled
if not is_covers_cache_enabled():
return cover_url
from shelfmark.core.config import config as app_config
# Encode the original URL and create a proxy URL
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
base_path = normalize_base_path(app_config.get("URL_BASE", ""))
if base_path:
return f"{base_path}/api/covers/{cache_id}?url={encoded_url}"
return f"/api/covers/{cache_id}?url={encoded_url}"