diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2fdbed..6d29f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,8 @@ jobs: - name: Run pip-audit run: | - pip install -r requirements.txt + python -m pip install -r requirements.txt + python -m pip install --upgrade "setuptools>=83.0.0" pip-audit lint-commits: diff --git a/config/config.yaml.example b/config/config.yaml.example index 3c0e405..0f950b6 100644 --- a/config/config.yaml.example +++ b/config/config.yaml.example @@ -54,7 +54,7 @@ server: reload: true base_url: "https://your-domain.com" # Replace with your domain autobrr_webhook_endpoint: "/webhook/audiobook-requests" # autobrr webook token location .env "AUTOBRR_TOKEN" - reply_token_ttl: 3600 # 1 hour in seconds + reply_token_ttl: 172800 # 48 hours in seconds # approve_success_autoclose: 10 # seconds to auto-close success page # reject_autoclose: 10 # seconds to auto-close rejection page # token_expired_autoclose: 10 # seconds to auto-close token expired page diff --git a/docs/vendor/audible/config/config.yaml.example b/docs/vendor/audible/config/config.yaml.example index 2bd88dd..9abbfa1 100644 --- a/docs/vendor/audible/config/config.yaml.example +++ b/docs/vendor/audible/config/config.yaml.example @@ -54,7 +54,7 @@ server: reload: true base_url: "https://your-domain.com" # Replace with your domain autobrr_webhook_endpoint: "/webhook/audiobook-requests" # autobrr webhook token location .env "AUTOBRR_TOKEN" - reply_token_ttl: 3600 # 1 hour in seconds + reply_token_ttl: 172800 # 48 hours in seconds # approve_success_autoclose: 10 # seconds to auto-close success page # reject_autoclose: 10 # seconds to auto-close rejection page # token_expired_autoclose: 10 # seconds to auto-close token expired page diff --git a/requirements.txt b/requirements.txt index 64da0d5..d8bea7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn python-dotenv -qbittorrent-api +qbittorrent-api>=2026.5.1 beautifulsoup4 aiofiles jinja2 diff --git a/service.sh b/service.sh new file mode 100755 index 0000000..aef4f00 --- /dev/null +++ b/service.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_NAME="$(basename "${SCRIPT_DIR}")" +SYSTEMCTL_BIN="$(command -v systemctl || true)" +JOURNALCTL_BIN="$(command -v journalctl || true)" +SUDO_BIN="$(command -v sudo || true)" + +COLOR_RESET=$'\033[0m' +COLOR_BLUE=$'\033[1;34m' +COLOR_CYAN=$'\033[1;36m' +COLOR_GREEN=$'\033[1;32m' +COLOR_RED=$'\033[1;31m' +COLOR_YELLOW=$'\033[1;33m' +COLOR_DIM=$'\033[2m' + +SERVICE_NAME="" + +banner() { + cat <<'EOF' + ___ ___ __ __ + / | __ _______/ (_) /_ ____ ____/ /__ + / /| |/ / / / __ / / / __ \/ __ \/ __ / _ \ + / ___ / /_/ / /_/ / / / /_/ / /_/ / /_/ / __/ +/_/ |_|\__,_/\__,_/_/_/_.___/\____/\__,_/\___/ + + _____ _ + / ___/___ ______ __(_)_______ + \__ \/ _ \/ ___/ | / / / ___/ _ \ + ___/ / __/ / | |/ / / /__/ __/ +/____/\___/_/ |___/_/\___/\___/ +EOF +} + +usage() { + banner + cat </dev/null | + awk -v unit="$1.service" '$1 == unit { found = 1 } END { exit(found ? 0 : 1) }' +} + +resolve_service_name() { + local candidate + + if [[ -n "${AUDIOBOOK_SERVICE_NAME:-}" ]]; then + SERVICE_NAME="${AUDIOBOOK_SERVICE_NAME}" + return + fi + + while IFS= read -r candidate; do + if [[ -n "${candidate}" ]] && unit_exists "${candidate}"; then + SERVICE_NAME="${candidate}" + return + fi + done < <(service_candidates | awk '!seen[$0]++') + + SERVICE_NAME="audiobook" +} + +list_matching_units() { + require_systemctl + "${SYSTEMCTL_BIN}" list-unit-files --type=service --no-legend --no-pager 2>/dev/null | + awk '{print $1}' | grep -E 'audio|book|approval|mam' || true +} + +ensure_known_unit() { + local matches + + if unit_exists "${SERVICE_NAME}"; then + return + fi + + matches="$(list_matching_units)" + + printf '%b\n' "${COLOR_RED}[MISS]${COLOR_RESET} ${SERVICE_NAME}.service is not installed on this machine." >&2 + if [[ -n "${matches}" ]]; then + printf '%b\n' "${COLOR_YELLOW}Try one of these units:${COLOR_RESET}" >&2 + printf '%s\n' "${matches}" >&2 + else + printf '%b\n' "${COLOR_YELLOW}No matching audiobook-style units were found.${COLOR_RESET}" >&2 + fi + printf '%b\n' "${COLOR_DIM}Override with:${COLOR_RESET} AUDIOBOOK_SERVICE_NAME= ./service.sh " >&2 + exit 1 +} + +require_systemctl() { + if [[ -z "${SYSTEMCTL_BIN}" ]]; then + printf '%b\n' "${COLOR_RED}systemctl was not found on this machine.${COLOR_RESET}" >&2 + exit 1 + fi +} + +require_journalctl() { + if [[ -z "${JOURNALCTL_BIN}" ]]; then + printf '%b\n' "${COLOR_RED}journalctl was not found on this machine.${COLOR_RESET}" >&2 + exit 1 + fi +} + +run_systemctl() { + require_systemctl + + if [[ "${EUID}" -eq 0 ]]; then + "${SYSTEMCTL_BIN}" "$@" + return + fi + + if [[ -n "${SUDO_BIN}" ]]; then + "${SUDO_BIN}" "${SYSTEMCTL_BIN}" "$@" + return + fi + + printf '%b\n' "${COLOR_RED}This command needs root privileges and sudo is unavailable.${COLOR_RESET}" >&2 + exit 1 +} + +run_journalctl() { + require_journalctl + + if [[ "${EUID}" -eq 0 ]]; then + "${JOURNALCTL_BIN}" "$@" + return + fi + + if [[ -n "${SUDO_BIN}" ]]; then + "${SUDO_BIN}" "${JOURNALCTL_BIN}" "$@" + return + fi + + "${JOURNALCTL_BIN}" "$@" +} + +print_header() { + banner + printf '%b\n' "${COLOR_BLUE}==>${COLOR_RESET} ${COLOR_CYAN}$1${COLOR_RESET}" + printf '%b\n' "${COLOR_DIM}Unit:${COLOR_RESET} ${SERVICE_NAME}" + printf '\n' +} + +show_status() { + local state + + ensure_known_unit + + if state="$("${SYSTEMCTL_BIN}" is-active "${SERVICE_NAME}" 2>/dev/null)"; then + printf '%b\n' "${COLOR_GREEN}[LIVE]${COLOR_RESET} ${SERVICE_NAME} is ${state}" + return + fi + + state="$("${SYSTEMCTL_BIN}" is-enabled "${SERVICE_NAME}" 2>/dev/null || true)" + if [[ -n "${state}" ]]; then + printf '%b\n' "${COLOR_YELLOW}[IDLE]${COLOR_RESET} ${SERVICE_NAME} is not active (${state})" + return + fi + + printf '%b\n' "${COLOR_RED}[MISS]${COLOR_RESET} ${SERVICE_NAME} is unknown to systemd" +} + +action_start() { + print_header "Ignition sequence" + ensure_known_unit + run_systemctl start "${SERVICE_NAME}" + show_status +} + +action_stop() { + print_header "Shutdown sequence" + ensure_known_unit + run_systemctl stop "${SERVICE_NAME}" + show_status +} + +action_restart() { + print_header "Hard reboot" + ensure_known_unit + run_systemctl restart "${SERVICE_NAME}" + show_status +} + +action_status() { + print_header "Status scan" + require_systemctl + show_status + printf '\n' + "${SYSTEMCTL_BIN}" status "${SERVICE_NAME}" --no-pager || true +} + +action_logs() { + print_header "Recent journal" + ensure_known_unit + run_journalctl -u "${SERVICE_NAME}" -n 50 --no-pager +} + +action_follow() { + print_header "Live journal tail" + ensure_known_unit + run_journalctl -u "${SERVICE_NAME}" -f +} + +action_enable() { + print_header "Boot hookup" + ensure_known_unit + run_systemctl enable "${SERVICE_NAME}" + "${SYSTEMCTL_BIN}" is-enabled "${SERVICE_NAME}" || true +} + +action_disable() { + print_header "Boot disconnect" + ensure_known_unit + run_systemctl disable "${SERVICE_NAME}" + "${SYSTEMCTL_BIN}" is-enabled "${SERVICE_NAME}" || true +} + +action_reload() { + print_header "Soft reload" + ensure_known_unit + run_systemctl reload "${SERVICE_NAME}" + show_status +} + +action_daemon_reload() { + print_header "Systemd refresh" + run_systemctl daemon-reload + printf '%b\n' "${COLOR_GREEN}[OK]${COLOR_RESET} systemd manager configuration reloaded" +} + +main() { + local action="${1:-help}" + + case "${action}" in + help|-h|--help) + usage + return + ;; + esac + + resolve_service_name + + case "${action}" in + start) + action_start + ;; + stop) + action_stop + ;; + restart) + action_restart + ;; + status) + action_status + ;; + logs) + action_logs + ;; + follow) + action_follow + ;; + enable) + action_enable + ;; + disable) + action_disable + ;; + reload) + action_reload + ;; + daemon-reload) + action_daemon_reload + ;; + *) + printf '%b\n\n' "${COLOR_RED}Unknown command:${COLOR_RESET} ${action}" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/src/db.py b/src/db.py index 6d57634..af0f504 100644 --- a/src/db.py +++ b/src/db.py @@ -31,6 +31,8 @@ # Default TTL, will be loaded from config on first use _ttl: int | None = None +DEFAULT_REPLY_TOKEN_TTL = 48 * 60 * 60 + def _get_ttl() -> int: """Lazy load TTL from config.""" @@ -38,10 +40,10 @@ def _get_ttl() -> int: if _ttl is None: try: config = load_config() - _ttl = config.get("server", {}).get("reply_token_ttl", 3600) + _ttl = config.get("server", {}).get("reply_token_ttl", DEFAULT_REPLY_TOKEN_TTL) except ConfigurationError: # Config not available (e.g., in tests), use default - _ttl = 3600 + _ttl = DEFAULT_REPLY_TOKEN_TTL return _ttl diff --git a/src/mam_api/adapter.py b/src/mam_api/adapter.py index 5874689..d70c546 100644 --- a/src/mam_api/adapter.py +++ b/src/mam_api/adapter.py @@ -19,6 +19,14 @@ log = get_logger(__name__) +def _serialize_datetime(value: Any) -> str | None: + if value is None: + return None + if hasattr(value, "isoformat"): + return str(value.isoformat()) + return str(value) + + def _sanitize_url_for_log(url: str) -> str: parsed = urlparse(url) return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) @@ -208,17 +216,8 @@ async def get_full_metadata(self, url: str) -> dict[str, Any] | None: url: MAM torrent URL Returns: - Dict with metadata including: - - asin: ASIN if available - - title: Torrent title - - authors: List of author names - - narrators: List of narrator names - - series: Series name if available - - series_position: Position in series - - description: Book description - - duration: Audio duration in seconds - - language: Language code - - mam_id: MAM torrent ID + Dict with lightweight top-level metadata plus a namespaced + mam_enrichment block containing torrent-specific fields. """ torrent = await self.get_torrent_data(url) if not torrent: @@ -235,6 +234,41 @@ async def get_full_metadata(self, url: str) -> dict[str, Any] | None: series_position = str(entry[1]) break + mam_enrichment = { + "mam_id": normalized.tid, + "asin": normalized.asin, + "isbn": normalized.isbn, + "title": normalized.title, + "uploader": normalized.uploader, + "uploader_id": normalized.uploader_id, + "authors": torrent.author_names, + "narrators": torrent.narrator_names, + "series": normalized.series, + "series_position": series_position, + "upload_notes": normalized.upload_notes, + "language": normalized.language_code, + "category": normalized.category, + "filetype": normalized.filetype, + "size": normalized.size, + "tags": normalized.tags, + "added": _serialize_datetime(normalized.added), + "free": normalized.free, + "vip": normalized.vip, + "fl_vip": normalized.fl_vip, + "seeders": normalized.seeders, + "leechers": normalized.leechers, + "times_completed": normalized.times_completed, + "comments": normalized.comments, + "audio": { + "duration": normalized.duration, + "codec": normalized.codec, + "bitrate": normalized.bitrate, + "channels": normalized.channels, + "sampling_rate": normalized.sample_rate, + "container": normalized.container, + }, + } + return { "asin": normalized.asin, "title": normalized.title, @@ -244,7 +278,8 @@ async def get_full_metadata(self, url: str) -> dict[str, Any] | None: "series_position": series_position, "description": torrent.description, "duration": normalized.duration, - "language": torrent.lang_code, + "language": normalized.language_code, "mam_id": normalized.tid, + "mam_enrichment": mam_enrichment, "source": "mam_api", } diff --git a/src/mam_api/models.py b/src/mam_api/models.py index 9927745..27d950e 100644 --- a/src/mam_api/models.py +++ b/src/mam_api/models.py @@ -116,13 +116,75 @@ class MamMediaInfoAudio(BaseModel): model_config = ConfigDict(extra="allow") Format: str | None = None + # Distinct raw MAM/MediaInfo casings; both are used by bitrate_value()'s fallback chain. BitRate: str | None = None + Bitrate: str | None = None BitRate_Mode: str | None = None Channels: int | None = None SamplingRate: str | None = None BitRate_Maximum: str | None = None Compression_Mode: str | None = None + def _extra_value(self, *keys: str) -> str | int | None: + extra = self.model_extra or {} + for key in keys: + value = extra.get(key) + if value not in (None, ""): + return value + return None + + @property + def codec_label(self) -> str | None: + parts: list[str] = [] + for value in ( + self.Format, + self._extra_value("CommercialName", "Format/String", "CodecID/Hint"), + self._extra_value("Format_Profile", "Format profile", "Format/Info"), + ): + if value is None: + continue + text = str(value).strip() + if not text: + continue + if text not in parts: + parts.append(text) + if not parts: + return None + return " / ".join(parts) + + @property + def bitrate_value(self) -> str | None: + for value in ( + self.BitRate, + self.Bitrate, + self._extra_value("BitRate/String", "Bit rate", "Bit rate mode"), + ): + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + @property + def sampling_rate_value(self) -> str | None: + for value in ( + self.SamplingRate, + self._extra_value("SamplingRate/String", "Sampling rate"), + ): + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + @property + def channels_value(self) -> int | str | None: + if self.Channels is not None: + return self.Channels + return self._extra_value("Channel(s)", "Channels", "Channel(s)_Original") + class MamMediaInfo(BaseModel): """ @@ -182,6 +244,8 @@ class MamTorrentRaw(BaseModel): comments: int = 0 # User-related + owner: int = 0 + owner_name: str | None = None bookmarked: str | None = None my_snatched: bool = False @@ -220,6 +284,7 @@ def _coerce_isbn_to_str(cls, v: Any) -> str | None: "language", "numfiles", "mediatype", + "owner", "vip_expire", "browseflags", "w", @@ -313,6 +378,26 @@ def added_utc(self) -> datetime | None: """Parse added timestamp to UTC datetime.""" return _parse_added_datetime(self.added) + @property + def uploader_id(self) -> int | None: + if self.owner: + return self.owner + if self.ownership and self.ownership[0]: + return self.ownership[0] + return None + + @property + def uploader_name(self) -> str | None: + if self.owner_name: + name = self.owner_name.strip() + if name: + return name + if self.ownership and len(self.ownership) >= 2: + name = str(self.ownership[1]).strip() + if name: + return name + return None + @property def author_names(self) -> list[str]: """Get sorted list of author names.""" @@ -346,6 +431,13 @@ def series_display(self) -> str: def to_normalized(self) -> MamTorrentNormalized: """Convert to normalized internal format.""" mi = self.mediainfo + audio = mi.Audio1 if mi else None + general = mi.General if mi else None + bitrate = audio.bitrate_value if audio else None + codec = audio.codec_label if audio else None + channels = audio.channels_value if audio else None + sample_rate = audio.sampling_rate_value if audio else None + return MamTorrentNormalized( tid=self.id, title=self.title, @@ -358,14 +450,24 @@ def to_normalized(self) -> MamTorrentNormalized: fl_vip=self.fl_vip, seeders=self.seeders, leechers=self.leechers, + times_completed=self.times_completed, + comments=self.comments, asin=self.asin, + isbn=self.isbn, author=", ".join(self.author_names), narrator=", ".join(self.narrator_names), series=self.series_display or None, - duration=(mi.General.Duration if mi and mi.General else None), - bitrate=(mi.Audio1.BitRate if mi and mi.Audio1 else None), - codec=(mi.Audio1.Format if mi and mi.Audio1 else None), + uploader=self.uploader_name, + uploader_id=self.uploader_id, + language_code=self.lang_code, + duration=(general.Duration if general else None), + bitrate=bitrate, + codec=codec, + channels=channels, + sample_rate=sample_rate, + container=(general.Format if general else None), tags=self.tags, + upload_notes=self.description, dl_token=self.dl, ) @@ -415,18 +517,28 @@ class MamTorrentNormalized(BaseModel): # Stats seeders: int leechers: int + times_completed: int = 0 + comments: int = 0 # Metadata asin: str = "" + isbn: str | None = None author: str = "" narrator: str = "" series: str | None = None + uploader: str | None = None + uploader_id: int | None = None + language_code: str = "" tags: str = "" # Audio info (from mediainfo) duration: str | None = None bitrate: str | None = None codec: str | None = None + channels: int | str | None = None + sample_rate: str | None = None + container: str | None = None + upload_notes: str | None = None # Download token (if dlLink was requested) dl_token: str | None = None diff --git a/src/metadata_coordinator.py b/src/metadata_coordinator.py index 741cf4d..ce4f11f 100644 --- a/src/metadata_coordinator.py +++ b/src/metadata_coordinator.py @@ -23,6 +23,7 @@ from src.config import load_config from src.logging_setup import get_logger from src.mam_api import MAMApiAdapter, MamApiError +from src.mam_api.client import MAM_AUTH_ERROR_MESSAGE log = get_logger(__name__) @@ -42,6 +43,18 @@ def __init__(self): log.info("coordinator.init", seed_authors=self.seed_authors, force_update=self.force_update) + @staticmethod + def _attach_mam_enrichment(metadata: dict[str, Any], mam_metadata: dict[str, Any] | None) -> dict[str, Any]: + """Attach non-authoritative MAM enrichment to a metadata result.""" + if not mam_metadata: + return metadata + + mam_enrichment = mam_metadata.get("mam_enrichment") + if mam_enrichment: + metadata["mam_enrichment"] = mam_enrichment + + return metadata + async def get_metadata_from_webhook(self, webhook_payload: dict[str, Any]) -> dict[str, Any] | None: """ Main workflow: Get metadata from webhook payload. @@ -59,17 +72,24 @@ async def get_metadata_from_webhook(self, webhook_payload: dict[str, Any]) -> di # Step 1: Try to extract ASIN from MAM URL if it's a MAM URL asin = None + mam_metadata: dict[str, Any] | None = None if url and "myanonamouse.net" in url: log.info("coordinator.step1.mam_extract") try: - asin = await self.mam_adapter.get_asin_from_url(url) + mam_metadata = await self.mam_adapter.get_full_metadata(url) + asin = mam_metadata.get("asin") if mam_metadata else None if asin: log.info("coordinator.step1.asin_found", asin=asin) + elif mam_metadata: + log.info("coordinator.step1.enrichment_found_without_asin", mam_id=mam_metadata.get("mam_id")) else: log.warning("coordinator.step1.no_asin", reason="mam_torrent_has_no_asin") - except MamApiError: - log.exception("coordinator.step1.mam_auth_error") - raise + except MamApiError as exc: + auth_error = MAM_AUTH_ERROR_MESSAGE in str(exc) or "MAM_ID not configured" in str(exc) + log.exception("coordinator.step1.mam_api_error", alert=auth_error) + if auth_error: + log.error("coordinator.step1.mam_auth_alert", error=str(exc)) + log.warning("coordinator.step1.continuing_without_mam", error=str(exc)) except httpx.RequestError: log.exception("coordinator.step1.network_error") except ValueError: @@ -97,6 +117,7 @@ async def get_metadata_from_webhook(self, webhook_payload: dict[str, Any]) -> di # Add webhook payload information metadata.update(self._add_webhook_info(webhook_payload)) + metadata = self._attach_mam_enrichment(metadata, mam_metadata) return metadata else: @@ -122,6 +143,7 @@ async def get_metadata_from_webhook(self, webhook_payload: dict[str, Any]) -> di # Add webhook payload information metadata.update(self._add_webhook_info(webhook_payload)) + metadata = self._attach_mam_enrichment(metadata, mam_metadata) return metadata else: diff --git a/src/notify/discord.py b/src/notify/discord.py index f8031b8..e04eb94 100644 --- a/src/notify/discord.py +++ b/src/notify/discord.py @@ -60,6 +60,11 @@ def send_discord( f"⏱️ **Runtime:** {runtime}" if runtime else None, f"📚 **Category:** {category}" if category else None, f"💾 **Size:** {size_fmt}" if size_fmt else None, + f"🎛️ **Audio:** {escape_md(fields['audio_summary'])}" if fields["audio_summary"] else None, + f"📈 **Torrent:** {escape_md(fields['torrent_health'])}" if fields["torrent_health"] else None, + f"🎟️ **Access:** {escape_md(fields['freeleech_label'])}" if fields["freeleech_label"] else None, + f"🕒 **Added:** {escape_md(fields['added_date'])}" if fields["added_date"] else None, + f"🆔 **ID:** {escape_md(fields['asin'] or fields['isbn'])}" if (fields["asin"] or fields["isbn"]) else None, f"📝 **Description:** {description}" if description else None, "", (f"[🌐 View]({url})" if url else "") + (f" | [📥 Download]({download_url})" if download_url else ""), diff --git a/src/notify/gotify.py b/src/notify/gotify.py index 4c0c0f9..9e0d20a 100644 --- a/src/notify/gotify.py +++ b/src/notify/gotify.py @@ -61,6 +61,11 @@ def send_gotify( f"**⏱️ Runtime:** {runtime}" if runtime else None, f"**📚 Category:** {category}" if category else None, f"**💾 Size:** {size_fmt}" if size_fmt else None, + f"**🎛️ Audio:** {escape_md(fields['audio_summary'])}" if fields["audio_summary"] else None, + f"**📈 Torrent:** {escape_md(fields['torrent_health'])}" if fields["torrent_health"] else None, + f"**🎟️ Access:** {escape_md(fields['freeleech_label'])}" if fields["freeleech_label"] else None, + f"**🕒 Added:** {escape_md(fields['added_date'])}" if fields["added_date"] else None, + f"**🆔 ID:** {escape_md(fields['asin'] or fields['isbn'])}" if (fields["asin"] or fields["isbn"]) else None, f"**📝 Description:** {description}" if description else None, f"![cover]({cover_url})" if cover_url else None, # Markdown image line f"[🌐 View]({view_url})", diff --git a/src/notify/ntfy.py b/src/notify/ntfy.py index 3a5afab..26c29bb 100644 --- a/src/notify/ntfy.py +++ b/src/notify/ntfy.py @@ -59,6 +59,11 @@ def send_ntfy( f"- ⏱️ **Runtime:** {runtime}" if runtime else None, f"- 📚 **Category:** {category}" if category else None, f"- 💾 **Size:** {size_fmt}" if size_fmt else None, + f"- 🎛️ **Audio:** {fields['audio_summary']}" if fields["audio_summary"] else None, + f"- 📈 **Torrent:** {fields['torrent_health']}" if fields["torrent_health"] else None, + f"- 🎟️ **Access:** {fields['freeleech_label']}" if fields["freeleech_label"] else None, + f"- 🕒 **Added:** {fields['added_date']}" if fields["added_date"] else None, + f"- 🆔 **ID:** {fields['asin'] or fields['isbn']}" if (fields["asin"] or fields["isbn"]) else None, " ---\n", "> 📝 **Description:**\n```\n" + description + "\n```" if description else None, (f"[🌐 View]({url})" if url else "") + (f" | [📥 Download]({download_url})" if download_url else ""), diff --git a/src/notify/pushover.py b/src/notify/pushover.py index 3f1cc15..6e0d0b7 100644 --- a/src/notify/pushover.py +++ b/src/notify/pushover.py @@ -46,16 +46,33 @@ def send_pushover( message = ( '🎉 NEW AUDIOBOOK
' f'🎧 Title: {escape(fields["title"])}
' - f'🔗 Series: {escape(fields["series"])}
' + ) + if fields["series"]: + message += f'🔗 Series: {escape(fields["series"])}
' + message += ( f'✍️ Author: {escape(fields["author"])}
' f'🏢 Publisher: {escape(fields["publisher"])}
' f'🎤 Narrators: {escape(", ".join(fields["narrators"]))}
' - f'📅 Release Date: {escape(fields["release_date"])}
' + ) + if fields["release_date"]: + message += f'📅 Release Date: {escape(fields["release_date"])}
' + message += ( f'⏱️ Runtime: {escape(fields["runtime"])}
' f'📚 Category: {escape(fields["category"])}
' f'💾 Size: {fields["size"]}
' f'📝 Description: {fields["description"]}
' ) + if fields["audio_summary"]: + message += f'🎛️ Audio: {escape(fields["audio_summary"])}
' + if fields["torrent_health"]: + message += f'📈 Torrent: {escape(fields["torrent_health"])}
' + if fields["freeleech_label"]: + message += f'🎟️ Access: {escape(fields["freeleech_label"])}
' + if fields["added_date"]: + message += f'🕒 Added: {escape(fields["added_date"])}
' + if fields["asin"] or fields["isbn"]: + identifier = fields["asin"] or fields["isbn"] + message += f'🆔 ID: {escape(identifier)}
' # Add url and download_url if fields["url"]: message += f'
🔗 URL: {escape(fields["url"])}' diff --git a/src/qbittorrent.py b/src/qbittorrent.py index 628cea4..2288d07 100644 --- a/src/qbittorrent.py +++ b/src/qbittorrent.py @@ -217,6 +217,65 @@ def find_info_bounds(data: bytes) -> tuple[int, int] | None: return None +def _torrent_add_result_succeeded(result: Any) -> bool: + """ + Interpret qbittorrent-api torrents_add responses across client versions. + + Returns True only for explicitly recognised success signals; returns False + (with a warning log) for anything unrecognised. + + Expected response shapes: + + 1. String (qBittorrent <= 4.x / API < v2.9): + - ``"Ok."`` → success + - ``"Fails."`` → failure (torrent rejected or already exists) + - any other string → unrecognised, treated as failure + + 2. Object with ``hash`` attribute (newer API): + - non-empty ``hash`` → success + + 3. Object with ``success_count`` (TorrentsAddedMetadata, API >= v2.9): + - ``success_count > 0`` → success + - ``added_torrent_ids`` non-empty collection → success + - ``pending_count > 0`` and ``failure_count == 0`` → success + - ``failure_count > 0`` → failure + + 4. Anything else → unrecognised, treated as failure with a warning log. + """ + if isinstance(result, str): + if result == "Ok.": + return True + if result == "Fails.": + return False + log.warning("qbittorrent.torrent.add.unrecognised_string_response", response=result) + return False + + torrent_hash = getattr(result, "hash", None) + if torrent_hash: + return True + + success_count = getattr(result, "success_count", None) + if isinstance(success_count, int) and success_count > 0: + return True + + added_torrent_ids = getattr(result, "added_torrent_ids", None) + if isinstance(added_torrent_ids, list | tuple | set) and len(added_torrent_ids) > 0: + return True + + pending_count = getattr(result, "pending_count", None) + failure_count = getattr(result, "failure_count", None) + if isinstance(pending_count, int) and pending_count > 0 and isinstance(failure_count, int) and failure_count == 0: + return True + + if isinstance(failure_count, int) and failure_count > 0: + return False + + log.warning( + "qbittorrent.torrent.add.unrecognised_response", response_type=type(result).__name__, response=str(result)[:200] + ) + return False + + # ============================================================================= # QBittorrent Manager (Singleton Pattern) # ============================================================================= @@ -443,24 +502,20 @@ def add_torrent_by_url( is_skip_checking=opts.is_skip_checking, ) - # Handle both old (string) and new (TorrentsAddedMetadata) responses + # Handle both old (string) and new (TorrentsAddedMetadata) responses. if isinstance(result, str): - if result == "Ok.": + success = _torrent_add_result_succeeded(result) + if success: log.info("qbittorrent.torrent.add.success") - return True elif result == "Fails.": log.warning("qbittorrent.torrent.add.rejected", url=url[:100]) - return False - else: - # Unknown string response, log it - log.debug("qbittorrent.torrent.add.response", response=result) - return True + return success else: # TorrentsAddedMetadata response (newer API versions) torrent_hash = getattr(result, "hash", None) if torrent_hash: log.info("qbittorrent.torrent.add.success", hash=torrent_hash) - return True + return _torrent_add_result_succeeded(result) except Conflict409Error: log.info("qbittorrent.torrent.already_exists") @@ -532,12 +587,7 @@ def add_torrent_file( is_skip_checking=opts.is_skip_checking, ) - success = False # Initialize before conditional assignment - if isinstance(result, str): - success = result == "Ok." - else: - # TorrentsAddedMetadata response - success = bool(getattr(result, "hash", None)) + success = _torrent_add_result_succeeded(result) if success: log.info("qbittorrent.torrent.file.add.success", filename=path.name) @@ -617,13 +667,7 @@ def add_torrent_data( is_skip_checking=opts.is_skip_checking, ) - # Handle both old (string) and new (TorrentsAddedMetadata) responses - success = False - if isinstance(result, str): - success = result == "Ok." - else: - # TorrentsAddedMetadata response - success = bool(getattr(result, "hash", None)) + success = _torrent_add_result_succeeded(result) if success: log.info("qbittorrent.torrent.data.add.success") diff --git a/src/utils.py b/src/utils.py index 319e43b..d6b4d3f 100644 --- a/src/utils.py +++ b/src/utils.py @@ -152,11 +152,89 @@ def build_notification_message(metadata: dict[str, Any], payload: dict[str, Any] return msg +def _get_mam_enrichment(metadata: dict[str, Any]) -> dict[str, Any]: + mam_enrichment = metadata.get("mam_enrichment") + return mam_enrichment if isinstance(mam_enrichment, dict) else {} + + +def _clean_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str) and value.strip(): + return [item.strip() for item in value.split(",") if item.strip()] + return [] + + +def _split_tags(value: Any) -> list[str]: + if not value: + return [] + return [tag.strip() for tag in re.split(r",\s*", str(value)) if tag.strip()] + + +def _coerce_optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + +def _format_bitrate(value: Any) -> str: + if value is None or value == "": + return "" + text = str(value).strip() + if not text: + return "" + try: + numeric = float(text) + except ValueError: + return text + + if numeric >= 1000: + kbps = numeric / 1000 + formatted = f"{kbps:.1f}".rstrip("0").rstrip(".") + return f"{formatted} kbps" + return f"{int(numeric)} bps" + + +def _format_sampling_rate(value: Any) -> str: + if value is None or value == "": + return "" + text = str(value).strip() + if not text: + return "" + try: + numeric = float(text) + except ValueError: + return text + + if numeric >= 1000: + khz = numeric / 1000 + formatted = f"{khz:.1f}".rstrip("0").rstrip(".") + return f"{formatted} kHz" + return f"{int(numeric)} Hz" + + +def _format_channels(value: Any) -> str: + if value is None or value == "": + return "" + try: + channels = int(value) + except (TypeError, ValueError): + return str(value) + return f"{channels} ch" + + def get_notification_fields(metadata: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: """ Extract and sanitize common fields for notification formatting. """ - title = clean_light_novel(metadata.get("title", "")) or "" + mam_enrichment = _get_mam_enrichment(metadata) + audio_data = mam_enrichment.get("audio") + mam_audio = audio_data if isinstance(audio_data, dict) else {} + + title = clean_light_novel(metadata.get("title") or mam_enrichment.get("title") or "") or "" # Series handling - try multiple field names series = "" @@ -168,13 +246,14 @@ def get_notification_fields(metadata: dict[str, Any], payload: dict[str, Any]) - else: series = str(series_name) if series_name else "" elif metadata.get("series"): - # Handle series as array or string + # Handle series as array or string. + # Audnex uses key "series"; Audible scraper uses key "title". series_data = metadata.get("series") if isinstance(series_data, list) and series_data: s = series_data[0] if isinstance(s, dict): - series_name = s.get("series", "") - series_seq = s.get("sequence", "") + series_name = s.get("series") or s.get("title") or s.get("name") or "" + series_seq = s.get("sequence") or s.get("position") or "" if series_name and series_seq: series = f"{series_name} (Vol. {series_seq})" elif series_name: @@ -189,11 +268,15 @@ def get_notification_fields(metadata: dict[str, Any], payload: dict[str, Any]) - series = f"{series_name} (Vol. {series_info['position']})" elif series_name: series = series_name + elif mam_enrichment.get("series"): + series = str(mam_enrichment.get("series") or "") # Clean series name series = clean_light_novel(series) or "" author = metadata.get("author", "") or metadata.get("book_author", "") + if not author: + author = ", ".join(_clean_list(mam_enrichment.get("authors"))) publisher = metadata.get("publisher", "") or metadata.get("book_publisher", "") # Narrator handling - try multiple field names @@ -215,29 +298,96 @@ def get_notification_fields(metadata: dict[str, Any], payload: dict[str, Any]) - narrators_raw = metadata.get("narrators_raw", []) narrators = [n.get("name", "") for n in narrators_raw if n.get("name")] + if not narrators: + narrators = _clean_list(mam_enrichment.get("narrators")) + # Fallback to payload if no narrators found if not narrators: narrators = payload.get("narrators", []) - release_date = format_release_date(metadata.get("release_date", "") or metadata.get("book_release_date", "")) - runtime = str(metadata.get("runtime_minutes", "") or metadata.get("book_duration", "")) - category = payload.get("category", "") - size = format_size(payload.get("size") or metadata.get("size")) - description = strip_html_tags( + release_date = format_release_date( + metadata.get("release_date") + or metadata.get("releaseDate") + or metadata.get("book_release_date") + or payload.get("release_date") + or "" + ) + runtime = str( + metadata.get("runtime_minutes", "") or metadata.get("book_duration", "") or mam_audio.get("duration") or "" + ) + category = payload.get("category", "") or metadata.get("category", "") or mam_enrichment.get("category", "") + size_value = payload.get("size") or metadata.get("size") or mam_enrichment.get("size") + size = format_size(size_value) if size_value is not None else "" + book_description = strip_html_tags( metadata.get("summary") or metadata.get("description", "") or metadata.get("book_description", "") ) + upload_notes = strip_html_tags(mam_enrichment.get("upload_notes")) + description = book_description or upload_notes url = payload.get("url") or metadata.get("url") download_url = payload.get("download_url") or metadata.get("download_url") cover_url = ( metadata.get("cover_url") or metadata.get("image") or metadata.get("cover") or metadata.get("book_cover") ) + language = str(metadata.get("language") or mam_enrichment.get("language") or "") + filetype = str(mam_enrichment.get("filetype") or metadata.get("format") or "") + isbn = str(mam_enrichment.get("isbn") or metadata.get("isbn") or "") + asin = str(metadata.get("asin") or mam_enrichment.get("asin") or "") + tags = str(mam_enrichment.get("tags") or metadata.get("tags") or "") + tag_list = _split_tags(tags) + uploader = str(mam_enrichment.get("uploader") or metadata.get("uploader") or payload.get("uploader") or "") + + audio_codec = str(mam_audio.get("codec") or "") + audio_bitrate = _format_bitrate(mam_audio.get("bitrate")) + audio_channels = _format_channels(mam_audio.get("channels")) + audio_sampling_rate = _format_sampling_rate(mam_audio.get("sampling_rate")) + audio_container = str(mam_audio.get("container") or "") + + audio_summary_parts = [] + if filetype: + audio_summary_parts.append(filetype) + if audio_codec and audio_codec.lower() != filetype.lower(): + audio_summary_parts.append(audio_codec) + if audio_bitrate: + audio_summary_parts.append(audio_bitrate) + if audio_channels: + audio_summary_parts.append(audio_channels) + if audio_sampling_rate: + audio_summary_parts.append(audio_sampling_rate) + audio_summary = " • ".join(audio_summary_parts) + + seeders = _coerce_optional_int(mam_enrichment.get("seeders")) + leechers = _coerce_optional_int(mam_enrichment.get("leechers")) + times_completed = _coerce_optional_int(mam_enrichment.get("times_completed")) + comments = _coerce_optional_int(mam_enrichment.get("comments")) + torrent_health_parts = [] + if seeders is not None: + torrent_health_parts.append(f"{seeders} seeders") + if leechers is not None: + torrent_health_parts.append(f"{leechers} leechers") + if times_completed is not None: + torrent_health_parts.append(f"{times_completed} completed") + torrent_health = " • ".join(torrent_health_parts) + comment_count_label = f"{comments} comments" if comments is not None else "" + + freeleech_flags = [] + if mam_enrichment.get("fl_vip"): + freeleech_flags.append("VIP Freeleech") + else: + if mam_enrichment.get("free") or payload.get("freeleech"): + freeleech_flags.append("Freeleech") + if mam_enrichment.get("vip"): + freeleech_flags.append("VIP") + freeleech_label = " • ".join(freeleech_flags) + added_date = format_release_date(str(mam_enrichment.get("added") or "")) + return { "title": title, "series": series, "author": author, "publisher": publisher, "narrators": narrators, + "narrator_text": ", ".join(narrators), "release_date": release_date, "runtime": runtime, "category": category, @@ -246,4 +396,28 @@ def get_notification_fields(metadata: dict[str, Any], payload: dict[str, Any]) - "url": url, "download_url": download_url, "cover_url": cover_url, + "language": language, + "filetype": filetype, + "asin": asin, + "isbn": isbn, + "tags": tags, + "tag_list": tag_list, + "uploader": uploader, + "audio_summary": audio_summary, + "audio_codec": audio_codec, + "audio_bitrate": audio_bitrate, + "audio_channels": audio_channels, + "audio_sampling_rate": audio_sampling_rate, + "audio_container": audio_container, + "torrent_health": torrent_health, + "seeders": seeders, + "leechers": leechers, + "times_completed": times_completed, + "comments": comments, + "comment_count_label": comment_count_label, + "freeleech_label": freeleech_label, + "freeleech_flags": freeleech_flags, + "added_date": added_date, + "upload_notes": upload_notes, + "has_mam_enrichment": bool(mam_enrichment), } diff --git a/src/webui.py b/src/webui.py index 3678356..fd8d42f 100644 --- a/src/webui.py +++ b/src/webui.py @@ -12,7 +12,7 @@ from src.qbittorrent import add_torrent_file_with_cookie from src.security import generate_csrf_token, get_client_ip from src.template_helpers import render_template -from src.utils import format_release_date, format_size, strip_html_tags +from src.utils import get_notification_fields router = APIRouter() @@ -90,32 +90,29 @@ async def approve(token: str, request: Request) -> HTMLResponse: author=metadata.get("author"), ) - # Format release_date to YYYY-MM-DD if present - release_date = metadata.get("release_date") or payload.get("release_date") or "" - metadata["release_date"] = format_release_date(str(release_date)) - # Format size to MB/GB if present - size = payload.get("size") or metadata.get("size") - if size: - metadata["size"] = format_size(size) - log.debug("webui.approve.size_formatted", token_id=token_fp, size=metadata["size"]) - # Ensure url and download_url are present - metadata["url"] = payload.get("url") - metadata["download_url"] = payload.get("download_url") - # Sanitize description to prevent XSS and strip dangerous HTML - raw_desc = metadata.get("description", "") or "" - cleaned_desc = strip_html_tags(raw_desc) - # Collapse excessive whitespace - cleaned_desc = "\n".join(line.strip() for line in cleaned_desc.splitlines() if line.strip()) - metadata["description"] = cleaned_desc + fields = get_notification_fields(metadata, payload) + log.debug( + "webui.approve.fields", + token_id=token_fp, + has_mam_enrichment=fields.get("has_mam_enrichment", False), + audio_summary=fields.get("audio_summary"), + torrent_health=fields.get("torrent_health"), + ) # Merge metadata and payload for template context - context = {"token": token, **payload, **metadata} + context = { + **payload, + **metadata, + **fields, + "narrator": fields.get("narrator_text", ""), + "token": token, + } # Add dynamic Open Graph/Twitter meta context.update( { - "og_title": metadata.get("title"), - "og_description": metadata.get("description") or payload.get("name"), - "og_image": metadata.get("cover_url") or metadata.get("image"), + "og_title": fields.get("title") or metadata.get("title"), + "og_description": fields.get("description") or payload.get("name"), + "og_image": fields.get("cover_url") or metadata.get("cover_url") or metadata.get("image"), } ) diff --git a/templates/approval.html b/templates/approval.html index e3da06b..11132e2 100644 --- a/templates/approval.html +++ b/templates/approval.html @@ -150,6 +150,96 @@

{{ title or name or '[ TITLE_NOT_FOUND ]' }}

{% endif %} + {% if audio_summary %} +
+
🎛️
+
+
AUDIO
+
{{ audio_summary }}
+
+
+ {% endif %} + + {% if torrent_health %} +
+
📈
+
+
TORRENT_HEALTH
+
{{ torrent_health }}
+
+
+ {% endif %} + + {% if comment_count_label %} +
+
💬
+
+
COMMENTS
+
{{ comment_count_label }}
+
+
+ {% endif %} + + {% if uploader %} +
+
🧑
+
+
UPLOADER
+
{{ uploader }}
+
+
+ {% endif %} + + {% if freeleech_label %} +
+
🎟️
+
+
ACCESS
+
{{ freeleech_label }}
+
+
+ {% endif %} + + {% if added_date %} +
+
🕒
+
+
MAM_ADDED
+
{{ added_date }}
+
+
+ {% endif %} + + {% if language or filetype %} +
+
🌐
+
+
LANGUAGE_FILE
+
{{ language }}{% if language and filetype %} • {% endif %}{{ filetype }}
+
+
+ {% endif %} + + {% if asin or isbn %} +
+
🆔
+
+
IDENTIFIERS
+
{% if asin %}ASIN {{ asin }}{% endif %}{% if asin and isbn %} • {% endif %}{% if isbn %}{{ isbn }}{% endif %}
+
+
+ {% endif %} + + {% if tag_list %} +
+
🏷️
+
+
TAGS
+
{{ tag_list|join(', ') }}
+
+
+ {% endif %} + {% if url %}
🔗
@@ -164,6 +254,13 @@

{{ title or name or '[ TITLE_NOT_FOUND ]' }}

{% endif %} + + {% if upload_notes and upload_notes != description %} +
+
📦 MAM UPLOAD NOTES
+
{{ upload_notes }}
+
+ {% endif %} diff --git a/tests/conftest.py b/tests/conftest.py index 5f2db7a..1f358f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -315,6 +315,7 @@ def coordinator(): coord.audnex = mock_audnex.return_value coord.audible = mock_audible.return_value coord.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) # type: ignore[method-assign] + coord.mam_adapter.get_full_metadata = AsyncMock(return_value=None) # type: ignore[method-assign] coord.audnex.get_book_by_asin = AsyncMock(return_value=None) # type: ignore[method-assign] coord.audible.search_from_webhook_name = AsyncMock(return_value=[]) # type: ignore[method-assign] yield coord diff --git a/tests/test_mam_api.py b/tests/test_mam_api.py index 2858347..433d452 100644 --- a/tests/test_mam_api.py +++ b/tests/test_mam_api.py @@ -58,6 +58,7 @@ def sample_torrent_data(): "my_bookmarked": "0", "browseflags": "00000000000000000000000000", "cat_name": "Audiobooks", + "catname": "Audiobooks", "added": "2025-12-20 10:30:00", "size": "524288000", "times_completed": "50", @@ -73,7 +74,8 @@ def sample_torrent_data(): "sub_cat": "14", "thumb": "https://example.com/thumb.jpg", "files": "25", - "mediainfo": '{"General": {"Duration": "12h 30m"}, "Audio1": {"Format": "MP3", "Bitrate": "128000"}}', + "tags": "mystery, thriller", + "mediainfo": '{"General": {"Duration": "12h 30m", "Format": "MPEG Audio"}, "Audio1": {"Format": "MP3", "Bitrate": "128000", "Channels": 2, "SamplingRate": "44100"}}', "ownership": '["67890", "downloader"]', } @@ -206,12 +208,40 @@ def test_mediainfo_parsing(self, sample_torrent_data): assert torrent.mediainfo.General is not None assert torrent.mediainfo.Audio1 is not None + def test_mediainfo_alias_parsing(self): + """Test mediainfo parsing when MAM returns alternate MediaInfo keys.""" + torrent = MamTorrentRaw( + id=123, + title="Alias Test", + owner_name="Uploader", + mediainfo={ + "General": {"Duration": "8h 00m", "Format": "M4B"}, + "Audio1": { + "Format": "AAC", + "CommercialName": "xHE-AAC", + "Format_Profile": "USAC", + "BitRate/String": "48.0 kb/s", + "Channel(s)": "2", + "SamplingRate/String": "24.0 kHz", + }, + }, + ) + + normalized = torrent.to_normalized() + assert normalized.codec == "AAC / xHE-AAC / USAC" + assert normalized.bitrate == "48.0 kb/s" + assert normalized.channels == "2" + assert normalized.sample_rate == "24.0 kHz" + assert normalized.uploader == "Uploader" + def test_ownership_parsing(self, sample_torrent_data): """Test ownership JSON-inside-string parsing.""" torrent = MamTorrentRaw(**sample_torrent_data) # ownership is (int, str) tuple assert torrent.ownership == (67890, "downloader") + assert torrent.uploader_name == "uploader123" + assert torrent.uploader_id == 12345 def test_numeric_fields(self, sample_torrent_data): """Test numeric field parsing - strings are preserved.""" @@ -283,6 +313,12 @@ def test_to_normalized_conversion(self, sample_torrent_data): assert "Test Author" in normalized.author # narrator is comma-joined string of narrator names assert "Narrator One" in normalized.narrator + assert normalized.bitrate == "128000" + assert normalized.channels == 2 + assert normalized.sample_rate == "44100" + assert normalized.times_completed == 50 + assert normalized.upload_notes == "This is a test audiobook description." + assert normalized.uploader == "uploader123" class TestMamSearchResponseRaw: @@ -603,6 +639,12 @@ async def test_get_full_metadata(self, sample_torrent_data): assert "Test Author" in metadata["authors"] assert "Narrator One" in metadata["narrators"] assert metadata["source"] == "mam_api" + assert metadata["mam_enrichment"]["seeders"] == 10 + assert metadata["mam_enrichment"]["times_completed"] == 50 + assert metadata["mam_enrichment"]["audio"]["bitrate"] == "128000" + assert metadata["mam_enrichment"]["audio"]["channels"] == 2 + assert metadata["mam_enrichment"]["uploader"] == "uploader123" + assert metadata["mam_enrichment"]["comments"] == 0 @pytest.mark.asyncio async def test_get_full_metadata_no_torrent(self): diff --git a/tests/test_metadata_coordinator.py b/tests/test_metadata_coordinator.py index bc99963..ec738a2 100644 --- a/tests/test_metadata_coordinator.py +++ b/tests/test_metadata_coordinator.py @@ -18,6 +18,7 @@ import pytest from src.mam_api import MamApiError +from src.mam_api.client import MAM_AUTH_ERROR_MESSAGE from src.metadata_coordinator import MetadataCoordinator, main @@ -184,7 +185,10 @@ class TestGetMetadataFromWebhook: async def test_webhook_mam_url_success(self, coordinator, sample_webhook_payload, sample_audnex_metadata): """Test successful ASIN extraction from MAM URL.""" # MAM returns ASIN - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + mam_enrichment = {"mam_id": 12345, "filetype": "MP3"} + coordinator.mam_adapter.get_full_metadata = AsyncMock( + return_value={"asin": "B0TEST1234", "mam_enrichment": mam_enrichment} + ) # Audnex returns metadata coordinator.audnex.get_book_by_asin = AsyncMock(return_value=sample_audnex_metadata.copy()) @@ -194,7 +198,8 @@ async def test_webhook_mam_url_success(self, coordinator, sample_webhook_payload assert result["asin"] == "B0TEST1234" assert result["source"] == "audnex" assert result["asin_source"] == "mam" - coordinator.mam_adapter.get_asin_from_url.assert_called_once() + assert result["mam_enrichment"] == mam_enrichment + coordinator.mam_adapter.get_full_metadata.assert_called_once_with(sample_webhook_payload["url"]) @pytest.mark.asyncio async def test_webhook_mam_no_asin_falls_back_to_audible( @@ -202,7 +207,8 @@ async def test_webhook_mam_no_asin_falls_back_to_audible( ): """Test fallback to Audible search when MAM returns no ASIN.""" # MAM returns no ASIN - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + mam_enrichment = {"mam_id": 54321, "filetype": "M4B"} + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"mam_enrichment": mam_enrichment}) # Audible search returns results coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) @@ -211,6 +217,7 @@ async def test_webhook_mam_no_asin_falls_back_to_audible( assert result is not None assert result["source"] == "audible" assert result["asin_source"] == "search" + assert result["mam_enrichment"] == mam_enrichment @pytest.mark.asyncio async def test_webhook_no_mam_url_goes_to_audible(self, coordinator, sample_audible_metadata): @@ -227,7 +234,7 @@ async def test_webhook_no_mam_url_goes_to_audible(self, coordinator, sample_audi assert result is not None assert result["source"] == "audible" # MAM should not be called for non-MAM URLs - coordinator.mam_adapter.get_asin_from_url.assert_not_called() + coordinator.mam_adapter.get_full_metadata.assert_not_called() @pytest.mark.asyncio async def test_webhook_audnex_failure_falls_back_to_audible( @@ -235,7 +242,7 @@ async def test_webhook_audnex_failure_falls_back_to_audible( ): """Test Audnex failure falls back to Audible.""" # MAM returns ASIN - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) # Audnex fails coordinator.audnex.get_book_by_asin = AsyncMock(return_value=None) # Audible search succeeds @@ -249,7 +256,7 @@ async def test_webhook_audnex_failure_falls_back_to_audible( @pytest.mark.asyncio async def test_webhook_all_sources_fail(self, coordinator, sample_webhook_payload): """Test when all metadata sources fail.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=None) result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -259,7 +266,7 @@ async def test_webhook_all_sources_fail(self, coordinator, sample_webhook_payloa @pytest.mark.asyncio async def test_webhook_mam_network_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test MAM network error is handled gracefully.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(side_effect=httpx.RequestError("Network error")) + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=httpx.RequestError("Network error")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -269,20 +276,42 @@ async def test_webhook_mam_network_error(self, coordinator, sample_webhook_paylo assert result["source"] == "audible" @pytest.mark.asyncio - async def test_webhook_mam_auth_error_raises(self, coordinator, sample_webhook_payload): - """Test MAM auth errors are surfaced instead of falling back to Audible.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(side_effect=MamApiError("Auth failed")) - coordinator.audible.search_from_webhook_name = AsyncMock() + @pytest.mark.parametrize("auth_error", [MAM_AUTH_ERROR_MESSAGE, "MAM_ID not configured"]) + async def test_webhook_mam_auth_error_falls_back_to_audible( + self, coordinator, sample_webhook_payload, sample_audible_metadata, auth_error + ): + """Test MAM auth errors do not prevent the Audible fallback search.""" + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=MamApiError(auth_error)) + coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) - with pytest.raises(MamApiError, match="Auth failed"): - await coordinator.get_metadata_from_webhook(sample_webhook_payload) + with patch("src.metadata_coordinator.log") as mock_log: + result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) - coordinator.audible.search_from_webhook_name.assert_not_called() + assert result is not None + assert result["source"] == "audible" + coordinator.audible.search_from_webhook_name.assert_called_once() + mock_log.exception.assert_any_call("coordinator.step1.mam_api_error", alert=True) + mock_log.error.assert_any_call("coordinator.step1.mam_auth_alert", error=auth_error) + + @pytest.mark.asyncio + async def test_webhook_mam_api_error_falls_back_to_audible( + self, coordinator, sample_webhook_payload, sample_audible_metadata + ): + """Test non-auth MAM API errors use the generic log event and fallback.""" + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=MamApiError("API failed")) + coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) + + with patch("src.metadata_coordinator.log") as mock_log: + result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) + + assert result is not None + assert result["source"] == "audible" + mock_log.exception.assert_any_call("coordinator.step1.mam_api_error", alert=False) @pytest.mark.asyncio async def test_webhook_audnex_network_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test Audnex network error falls back to Audible.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) coordinator.audnex.get_book_by_asin = AsyncMock(side_effect=httpx.RequestError("Network error")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) @@ -294,7 +323,7 @@ async def test_webhook_audnex_network_error(self, coordinator, sample_webhook_pa @pytest.mark.asyncio async def test_webhook_audnex_value_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test Audnex ValueError (malformed response) falls back.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) coordinator.audnex.get_book_by_asin = AsyncMock(side_effect=ValueError("Malformed response")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) @@ -306,7 +335,7 @@ async def test_webhook_audnex_value_error(self, coordinator, sample_webhook_payl @pytest.mark.asyncio async def test_webhook_audnex_unexpected_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test Audnex unexpected error is handled.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) coordinator.audnex.get_book_by_asin = AsyncMock(side_effect=RuntimeError("Unexpected")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) @@ -318,7 +347,7 @@ async def test_webhook_audnex_unexpected_error(self, coordinator, sample_webhook @pytest.mark.asyncio async def test_webhook_audible_network_error_raises(self, coordinator, sample_webhook_payload): """Test Audible network error raises ValueError.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(side_effect=httpx.RequestError("Network error")) with pytest.raises(ValueError, match="Could not fetch metadata"): @@ -327,7 +356,7 @@ async def test_webhook_audible_network_error_raises(self, coordinator, sample_we @pytest.mark.asyncio async def test_webhook_audible_value_error_raises(self, coordinator, sample_webhook_payload): """Test Audible ValueError raises.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(side_effect=ValueError("Malformed response")) with pytest.raises(ValueError, match="Could not fetch metadata"): @@ -336,7 +365,7 @@ async def test_webhook_audible_value_error_raises(self, coordinator, sample_webh @pytest.mark.asyncio async def test_webhook_audible_unexpected_error_returns_none(self, coordinator, sample_webhook_payload): """Test Audible unexpected error returns None.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(side_effect=RuntimeError("Unexpected")) result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -350,7 +379,7 @@ async def test_webhook_passes_seed_authors_and_update( """Test that seed_authors and update params are passed to Audnex.""" coordinator.seed_authors = True coordinator.force_update = True - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) coordinator.audnex.get_book_by_asin = AsyncMock(return_value=sample_audnex_metadata.copy()) await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -368,7 +397,7 @@ async def test_webhook_empty_name_still_works(self, coordinator, sample_audible_ "name": "", "url": "https://www.myanonamouse.net/t/12345", } - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) result = await coordinator.get_metadata_from_webhook(payload) @@ -379,7 +408,7 @@ async def test_webhook_empty_name_still_works(self, coordinator, sample_audible_ @pytest.mark.asyncio async def test_webhook_mam_value_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test MAM ValueError (malformed response) continues to Audible.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(side_effect=ValueError("Malformed response")) + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=ValueError("Malformed response")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -390,7 +419,7 @@ async def test_webhook_mam_value_error(self, coordinator, sample_webhook_payload @pytest.mark.asyncio async def test_webhook_mam_unexpected_error(self, coordinator, sample_webhook_payload, sample_audible_metadata): """Test MAM unexpected error continues to Audible.""" - coordinator.mam_adapter.get_asin_from_url = AsyncMock(side_effect=RuntimeError("Unexpected")) + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=RuntimeError("Unexpected")) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) result = await coordinator.get_metadata_from_webhook(sample_webhook_payload) @@ -928,7 +957,7 @@ async def test_full_workflow_mam_to_audnex_to_chapters( ): """Test complete workflow: webhook → MAM → Audnex → chapters.""" # Setup mocks - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B0TEST1234") + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value={"asin": "B0TEST1234"}) coordinator.audnex.get_book_by_asin = AsyncMock(return_value=sample_audnex_metadata.copy()) coordinator.audnex.get_chapters_by_asin = AsyncMock(return_value=sample_chapters) @@ -948,7 +977,7 @@ async def test_full_workflow_fallback_to_audible( ): """Test workflow with fallback: MAM fails → Audnex fails → Audible succeeds.""" # MAM fails - coordinator.mam_adapter.get_asin_from_url = AsyncMock(side_effect=httpx.RequestError("MAM down")) + coordinator.mam_adapter.get_full_metadata = AsyncMock(side_effect=httpx.RequestError("MAM down")) # Audible succeeds coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[sample_audible_metadata.copy()]) diff --git a/tests/test_metadata_extended.py b/tests/test_metadata_extended.py index 2e52479..0148320 100644 --- a/tests/test_metadata_extended.py +++ b/tests/test_metadata_extended.py @@ -128,7 +128,20 @@ async def test_coordinator_webhook_success(self, coordinator): "download_url": "http://example.com/download.torrent", } - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value="B123456789") + coordinator.mam_adapter.get_full_metadata = AsyncMock( + return_value={ + "asin": "B123456789", + "mam_id": 12345, + "mam_enrichment": { + "mam_id": 12345, + "filetype": "MP3", + "seeders": 10, + "leechers": 2, + "times_completed": 50, + "audio": {"codec": "MP3", "bitrate": "128000"}, + }, + } + ) coordinator.audnex.get_book_by_asin = AsyncMock( return_value={"title": "Test Book", "authors": [{"name": "Test Author"}], "asin": "B123456789"} ) @@ -138,6 +151,7 @@ async def test_coordinator_webhook_success(self, coordinator): assert result["title"] == "Test Book" assert result["asin"] == "B123456789" assert result["source"] == "audnex" + assert result["mam_enrichment"]["mam_id"] == 12345 @pytest.mark.asyncio @pytest.mark.no_mock_external_apis @@ -148,7 +162,7 @@ async def test_coordinator_fallback_to_audible_search(self, coordinator): "download_url": "http://example.com/download.torrent", } - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audnex.get_book_by_asin = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock( return_value=[{"title": "Resolved Book", "asin": "B987654321"}] @@ -170,7 +184,7 @@ async def test_coordinator_returns_none_when_no_metadata_found(self, coordinator "download_url": "http://example.com/download.torrent", } - coordinator.mam_adapter.get_asin_from_url = AsyncMock(return_value=None) + coordinator.mam_adapter.get_full_metadata = AsyncMock(return_value=None) coordinator.audnex.get_book_by_asin = AsyncMock(return_value=None) coordinator.audible.search_from_webhook_name = AsyncMock(return_value=[]) diff --git a/tests/test_notify_formatting.py b/tests/test_notify_formatting.py index 0de20c4..8a3663f 100644 --- a/tests/test_notify_formatting.py +++ b/tests/test_notify_formatting.py @@ -21,6 +21,20 @@ "series_primary": {"name": "Series Name", "position": "1"}, "release_date": "2020-01-01T00:00:00Z", "narrators": ["Narrator A"], + "mam_enrichment": { + "asin": "B000123456", + "added": "2025-12-20T10:30:00+00:00", + "free": True, + "seeders": 10, + "leechers": 2, + "times_completed": 50, + "audio": { + "codec": "AAC / xHE-AAC / USAC", + "bitrate": "128000", + "channels": 2, + "sampling_rate": "44100", + }, + }, } sample_payload = { "name": "TorrentName", @@ -55,6 +69,11 @@ def test_pushover_message_formatting(mock_httpx_globally): assert status == 200 assert resp["status"] == 1 assert mock_httpx_globally["post"].called + _args, kwargs = mock_httpx_globally["post"].call_args + assert "AAC / xHE-AAC / USAC" in kwargs["data"]["message"] + assert "128 kbps" in kwargs["data"]["message"] + assert "10 seeders" in kwargs["data"]["message"] + assert "Freeleech" in kwargs["data"]["message"] def test_gotify_message_formatting(mock_httpx_globally): @@ -76,6 +95,10 @@ def test_gotify_message_formatting(mock_httpx_globally): assert status == 200 assert "id" in resp assert mock_httpx_globally["post"].called + _args, kwargs = mock_httpx_globally["post"].call_args + assert "AAC / xHE-AAC / USAC" in kwargs["json"]["message"] + assert "128 kbps" in kwargs["json"]["message"] + assert "10 seeders" in kwargs["json"]["message"] def test_discord_message_formatting(mock_httpx_globally): @@ -89,6 +112,10 @@ def test_discord_message_formatting(mock_httpx_globally): assert status == 204 assert mock_httpx_globally["post"].called + _args, kwargs = mock_httpx_globally["post"].call_args + assert "AAC / xHE-AAC / USAC" in kwargs["json"]["embeds"][0]["description"] + assert "128 kbps" in kwargs["json"]["embeds"][0]["description"] + assert "10 seeders" in kwargs["json"]["embeds"][0]["description"] def test_ntfy_message_formatting(mock_httpx_globally): @@ -108,6 +135,10 @@ def test_ntfy_message_formatting(mock_httpx_globally): assert status == 200 assert resp["result"] == "ok" assert mock_httpx_globally["post"].called + _args, kwargs = mock_httpx_globally["post"].call_args + assert "AAC / xHE-AAC / USAC" in kwargs["json"]["message"] + assert "128 kbps" in kwargs["json"]["message"] + assert "10 seeders" in kwargs["json"]["message"] @pytest.mark.parametrize("field", ["url", "download_url"]) diff --git a/tests/test_qbittorrent.py b/tests/test_qbittorrent.py index 8790c63..a72cffd 100644 --- a/tests/test_qbittorrent.py +++ b/tests/test_qbittorrent.py @@ -155,6 +155,27 @@ def test_add_torrent_by_url_success(self, monkeypatch): assert result is True mock_client.torrents_add.assert_called_once() + def test_add_torrent_by_url_metadata_failure(self, monkeypatch): + monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") + monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") + monkeypatch.setenv("QBITTORRENT_PASSWORD", "password") + + with patch("src.qbittorrent.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.app_version.return_value = "4.5.0" + metadata = MagicMock() + metadata.hash = None + metadata.success_count = 0 + metadata.added_torrent_ids = [] + metadata.failure_count = 1 + metadata.pending_count = 0 + mock_client.torrents_add.return_value = metadata + mock_client_class.return_value = mock_client + + manager = QBittorrentManager() + + assert manager.add_torrent_by_url("magnet:?xt=urn:btih:abc123") is False + def test_add_torrent_by_url_with_cookie(self, monkeypatch): monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") @@ -320,6 +341,88 @@ def test_add_torrent_file_with_cookie_success(self, monkeypatch): assert call_kwargs.get("torrent_files") == fake_torrent_data assert call_kwargs.get("category") == "audiobooks" + def test_add_torrent_file_with_cookie_metadata_counts_success(self, monkeypatch): + """Test adding torrent with cookie when qBittorrent returns metadata counters.""" + monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") + monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") + monkeypatch.setenv("QBITTORRENT_PASSWORD", "password") + + fake_torrent_data = b"d8:announce3:url4:infod4:name4:teste" + + with ( + patch("src.qbittorrent.Client") as mock_client_class, + patch("src.qbittorrent.httpx.Client") as mock_httpx_class, + ): + mock_client = MagicMock() + mock_client.app_version.return_value = "4.5.0" + metadata = MagicMock() + metadata.hash = None + metadata.success_count = 1 + metadata.added_torrent_ids = ["abc123def456"] + metadata.failure_count = 0 + metadata.pending_count = 0 + mock_client.torrents_add.return_value = metadata + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = fake_torrent_data + mock_response.headers = {"content-type": "application/x-bittorrent"} + mock_httpx = MagicMock() + mock_httpx.get.return_value = mock_response + mock_httpx.__enter__ = MagicMock(return_value=mock_httpx) + mock_httpx.__exit__ = MagicMock(return_value=False) + mock_httpx_class.return_value = mock_httpx + + result = add_torrent_file_with_cookie( + download_url="http://example.com/test.torrent", + name="Test Torrent", + cookie="session=abc123", + ) + + assert result is True + mock_client.torrents_add.assert_called_once() + + def test_add_torrent_file_with_cookie_metadata_counts_failure(self, monkeypatch): + """Test that metadata with success_count=0 and no other success signals returns False.""" + monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") + monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") + monkeypatch.setenv("QBITTORRENT_PASSWORD", "password") + + fake_torrent_data = b"d8:announce3:url4:infod4:name4:teste" + + with ( + patch("src.qbittorrent.Client") as mock_client_class, + patch("src.qbittorrent.httpx.Client") as mock_httpx_class, + ): + mock_client = MagicMock() + mock_client.app_version.return_value = "4.5.0" + metadata = MagicMock() + metadata.hash = None + metadata.success_count = 0 + metadata.added_torrent_ids = [] + metadata.failure_count = 0 + metadata.pending_count = 0 + mock_client.torrents_add.return_value = metadata + mock_client_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.content = fake_torrent_data + mock_response.headers = {"content-type": "application/x-bittorrent"} + mock_httpx = MagicMock() + mock_httpx.get.return_value = mock_response + mock_httpx.__enter__ = MagicMock(return_value=mock_httpx) + mock_httpx.__exit__ = MagicMock(return_value=False) + mock_httpx_class.return_value = mock_httpx + + result = add_torrent_file_with_cookie( + download_url="http://example.com/test.torrent", + name="Test Torrent", + cookie="session=abc123", + ) + + assert result is False + mock_client.torrents_add.assert_called_once() + def test_add_torrent_file_invalid_url(self, monkeypatch): """Test that invalid URLs are rejected.""" monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") diff --git a/tests/test_qbittorrent_coverage.py b/tests/test_qbittorrent_coverage.py index e0e01f3..c95a3e7 100644 --- a/tests/test_qbittorrent_coverage.py +++ b/tests/test_qbittorrent_coverage.py @@ -202,7 +202,7 @@ def test_api_connection_error_during_add(self, monkeypatch): manager.add_torrent_by_url("magnet:?xt=urn:btih:abc123") def test_unknown_string_response(self, monkeypatch): - """Test handling of unknown string response.""" + """Test that an unrecognised string response is not treated as success.""" monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") monkeypatch.setenv("QBITTORRENT_PASSWORD", "password") @@ -210,15 +210,13 @@ def test_unknown_string_response(self, monkeypatch): with patch("src.qbittorrent.Client") as mock_client_class: mock_client = MagicMock() mock_client.app_version.return_value = "4.5.0" - # Return an unknown string response - mock_client.torrents_add.return_value = "Unknown response" + mock_client.torrents_add.return_value = "Unexpected." mock_client_class.return_value = mock_client manager = QBittorrentManager() - # Should return True for unknown responses (assume success) result = manager.add_torrent_by_url("magnet:?xt=urn:btih:abc123") - assert result is True + assert result is False def test_metadata_response_with_hash(self, monkeypatch): """Test handling of TorrentsAddedMetadata response with hash.""" @@ -240,8 +238,8 @@ def test_metadata_response_with_hash(self, monkeypatch): result = manager.add_torrent_by_url("magnet:?xt=urn:btih:abc123") assert result is True - def test_metadata_response_without_hash(self, monkeypatch): - """Test handling of TorrentsAddedMetadata response without hash.""" + def test_unrecognised_metadata_response_returns_false(self, monkeypatch): + """Test that unrecognised torrent-add metadata is not treated as success.""" monkeypatch.setenv("QBITTORRENT_URL", "http://localhost:8080") monkeypatch.setenv("QBITTORRENT_USERNAME", "admin") monkeypatch.setenv("QBITTORRENT_PASSWORD", "password") @@ -249,16 +247,15 @@ def test_metadata_response_without_hash(self, monkeypatch): with patch("src.qbittorrent.Client") as mock_client_class: mock_client = MagicMock() mock_client.app_version.return_value = "4.5.0" - # Return a metadata object without hash + # Return an unrecognised metadata object without a success signal. metadata = MagicMock(spec=[]) # No hash attribute mock_client.torrents_add.return_value = metadata mock_client_class.return_value = mock_client manager = QBittorrentManager() - # Should still return True (benefit of doubt) result = manager.add_torrent_by_url("magnet:?xt=urn:btih:abc123") - assert result is True + assert result is False class TestAddTorrentFile: diff --git a/tests/test_utils_extra.py b/tests/test_utils_extra.py index 5508509..bb12419 100644 --- a/tests/test_utils_extra.py +++ b/tests/test_utils_extra.py @@ -5,6 +5,7 @@ from src.db import delete_request, get_request, save_request from src.metadata import clean_metadata from src.utils import ( + _format_sampling_rate, build_notification_message, clean_author_list, format_release_date, @@ -128,6 +129,93 @@ def test_get_notification_fields_no_size(): meta = {"title": "Test", "author": "Auth"} payload = {"url": "u", "download_url": "d"} # No size fields = get_notification_fields(meta, payload) - assert fields["size"] == "?" + assert fields["size"] == "" assert fields["title"] == "Test" assert fields["series"] == "" # Empty series + + +def test_get_notification_fields_uses_payload_release_date(): + fields = get_notification_fields({"title": "Test"}, {"release_date": "2026-07-20T12:00:00Z"}) + + assert fields["release_date"] == "2026-07-20" + + +def test_format_sampling_rate_below_one_khz_uses_hz(): + assert _format_sampling_rate("800") == "800 Hz" + + +def test_get_notification_fields_with_mam_enrichment(): + meta = { + "title": "", + "mam_enrichment": { + "title": "Torrent Title", + "uploader": "UploaderUser", + "authors": ["MAM Author"], + "narrators": ["MAM Narrator"], + "series": "Series Name #1", + "language": "ENG", + "filetype": "MP3", + "asin": "B0TEST1234", + "isbn": "ASIN:B0TEST1234", + "tags": "mystery, thriller", + "upload_notes": "

Encoded from CD

", + "added": "2025-12-20T10:30:00+00:00", + "seeders": 10, + "leechers": 2, + "times_completed": 50, + "comments": 12, + "free": True, + "audio": { + "duration": "12h 30m", + "codec": "AAC / xHE-AAC / USAC", + "bitrate": "128000", + "channels": 2, + "sampling_rate": "44100", + }, + }, + } + payload = {"url": "u", "download_url": "d", "size": 1024 * 1024 * 500} + + fields = get_notification_fields(meta, payload) + + assert fields["title"] == "Torrent Title" + assert fields["author"] == "MAM Author" + assert fields["narrators"] == ["MAM Narrator"] + assert fields["series"] == "Series Name #1" + assert fields["runtime"] == "12h 30m" + assert fields["audio_summary"] == "MP3 • AAC / xHE-AAC / USAC • 128 kbps • 2 ch • 44.1 kHz" + assert fields["torrent_health"] == "10 seeders • 2 leechers • 50 completed" + assert fields["freeleech_label"] == "Freeleech" + assert fields["added_date"] == "2025-12-20" + assert fields["description"] == "Encoded from CD" + assert fields["tag_list"] == ["mystery", "thriller"] + assert fields["uploader"] == "UploaderUser" + assert fields["comment_count_label"] == "12 comments" + + +def test_get_notification_fields_audible_series_key(): + """Audible scraper uses 'title' inside series items, not 'series'.""" + meta = { + "title": "The Angel Next Door Spoils Me Rotten, Vol. 3", + "series": [{"title": "The Angel Next Door Spoils Me Rotten", "sequence": "3"}], + "releaseDate": "2023-06-27", + "author": "Saekisan", + "narrators": ["Greg D. Barnett"], + } + payload = {} + fields = get_notification_fields(meta, payload) + assert fields["series"] == "The Angel Next Door Spoils Me Rotten (Vol. 3)" + assert fields["release_date"] == "2023-06-27" + + +def test_get_notification_fields_audible_series_no_sequence(): + """Audible series item with title but no sequence.""" + meta = { + "title": "Some Book", + "series": [{"title": "Some Series", "sequence": ""}], + "releaseDate": "2024-01-15T00:00:00Z", + } + payload = {} + fields = get_notification_fields(meta, payload) + assert fields["series"] == "Some Series" + assert fields["release_date"] == "2024-01-15" diff --git a/tests/test_webui_extended.py b/tests/test_webui_extended.py index 3298c0e..a21379e 100644 --- a/tests/test_webui_extended.py +++ b/tests/test_webui_extended.py @@ -20,6 +20,22 @@ def test_approve_page_valid_token(self, test_client, valid_token): assert "Test Book" in resp.text assert "Test Author" in resp.text + def test_approve_page_uses_path_token_over_payload_or_metadata(self, test_client): + token = "path_token" + metadata = {"title": "Test Book", "token": "metadata_token"} + payload = {"token": "payload_token"} + save_request(token, metadata, payload) + + try: + response = test_client.get(f"/approve/{token}") + + assert response.status_code == 200 + assert f'action="/approve/{token}"' in response.text + assert "metadata_token" not in response.text + assert "payload_token" not in response.text + finally: + delete_request(token) + def test_approve_page_invalid_token(self, test_client): resp = test_client.get("/approve/nonexistent_token") assert resp.status_code in (401, 410, 404) @@ -163,6 +179,51 @@ def test_metadata_formatting_in_approval_page(self, test_client): finally: delete_request(token) + def test_mam_enrichment_in_approval_page(self, test_client): + token = "test_mam_enrichment_token" + metadata = { + "title": "Test Title", + "author": "Author Name", + "description": "Primary description", + "mam_enrichment": { + "uploader": "UploaderUser", + "filetype": "MP3", + "language": "ENG", + "asin": "B0TEST1234", + "tags": "mystery, thriller", + "upload_notes": "

Encoded from CD master

", + "added": "2025-12-20T10:30:00+00:00", + "free": True, + "seeders": 10, + "leechers": 2, + "times_completed": 50, + "comments": 12, + "audio": { + "codec": "AAC / xHE-AAC / USAC", + "bitrate": "128000", + "channels": 2, + "sampling_rate": "44100", + }, + }, + } + payload = {"url": "http://test.com", "download_url": "http://test.com/download", "size": 1024 * 1024 * 100} + save_request(token, metadata, payload) + + try: + resp = test_client.get(f"/approve/{token}") + assert resp.status_code == 200 + assert "AAC / xHE-AAC / USAC" in resp.text + assert "128 kbps" in resp.text + assert "10 seeders" in resp.text + assert "Freeleech" in resp.text + assert "2025-12-20" in resp.text + assert "mystery, thriller" in resp.text + assert "Encoded from CD master" in resp.text + assert "UploaderUser" in resp.text + assert "12 comments" in resp.text + finally: + delete_request(token) + # NOTE: test_token_expiry_handling has been removed due to flaky behavior with # session-scoped test_client and event loop timing issues during teardown. # The token expiry functionality is tested indirectly by other tests that