feat: narrator, series and bitrate columns for MyAnonamouse results (#1390)

# feat: narrator, series and bitrate columns for MyAnonamouse results

Addresses #605 and #934 (narrator in the release list).

## Problem

Both requests were closed because the narrator isn't in Torznab results,
which is correct. Prowlarr's MyAnonamouse indexer reads `author_info`
but drops the `narrator_info` and `series_info` MAM returns next to it,
and neither Prowlarr's `ReleaseInfo` nor the Torznab output has a field
for them. Shelfmark's size tooltip already looks for a `narrator`
Torznab attribute, but nothing ever sends one.

When a book has several narrations, choosing one means going back and
forth between Shelfmark and the tracker.

## Approach

Optional, opt-in enrichment using the user's own MAM session (`mam_id`),
the same approach AudioBookRequest's MAM indexer uses:

1. After the Prowlarr search, releases whose info URL is
`…myanonamouse.net/t/<id>` are collected.
2. Shelfmark sends the same query text to MAM's JSON search
(`/tor/js/loadSearchJSONbasic.php`, normally one request, at most 3),
and matches torrents back to Prowlarr's results by torrent ID. The MAM
origin is taken from the result URL, so a custom Prowlarr MAM base URL
is respected, and the cookie is only ever sent to `*.myanonamouse.net`.
3. Adds `extra.narrator`, `extra.series` (`The Sun Eater #1`) and
`extra.bitrate` / `extra.bitrate_value`. MAM has no bitrate field, so
it's parsed from the uploader's free-text tags (`64 kbps`) and some
releases won't have one.

Lookups are cached per torrent for an hour, respect the existing
Prowlarr search deadline, go through the configured proxy
(`get_proxies`), and never fail the search: a 403, timeout or bad JSON
is logged and the list renders without the extra details.

## Changes

- **`release_sources/prowlarr/mam.py`** (new): small MAM client
(`search`, and `get_username` for the test button), `narrator_info` /
`series_info` / tags parsing, cached best-effort
`lookup_torrent_details()`. A 403 includes MAM's reply and a note about
the IP/ASN lock.
- **`release_sources/prowlarr/source.py`**: enrichment after the result
loop. Series, Narrator and Bitrate columns only when a MAM ID is
configured, since otherwise they would be empty for every row. A Torznab
`bitrate` attribute from other indexers is also mapped to
`extra.bitrate`.
- **`release_sources/prowlarr/settings.py`**: "MyAnonamouse Enrichment"
section with `PROWLARR_MAM_ID` (password field, env-overridable like
every setting) and a **Test MAM Session** button.
- **`release_sources/__init__.py`**: `ColumnSchema` gains optional
`setting_key` and `content_types`. The new `apply_column_visibility()`
drops gated columns and their grid tracks. Neither field is serialized.
- **`main.py`**: `/api/releases` applies `apply_column_visibility()`
with the request's content type and the user's effective settings.
- **`config/settings.py` / `users_settings.py`**: Search Mode › "Release
List Columns" with `SHOW_SERIES_COLUMN`, `SHOW_NARRATOR_COLUMN` and
`SHOW_BITRATE_COLUMN`, all default on and user-overridable. Narrator and
bitrate are audiobook-only, series shows for both. AudiobookBay's
existing bitrate column now follows the bitrate toggle.
- **Frontend**: text cells truncate with a hover title; the mobile info
line wraps and skips empty text/number cells so blank optional columns
don't leave orphan `·` separators; the size tooltip no longer lists
Bitrate twice.
- **Docs**: new `docs/myanonamouse-enrichment.md` (linked from the
index), and a regenerated `environment-variables.md`. The regeneration
also picked up a few pre-existing drifts from `main` (the Libgen
section, `AA_DEFAULT_SORT` default, a duplicate `BOOK_LANGUAGE` row). I
can drop those if you'd rather keep this diff focused.

## ⚠️ MAM sessions are IP/ASN-locked

MyAnonamouse locks each session to one IP or ASN. Reusing the session
Prowlarr (or a seedbox script) uses will often **403**. **A separate MAM
session for Shelfmark will likely be needed** when Shelfmark reaches MAM
from a different IP (another host, a VPN container, or a proxy in
Shelfmark's Network settings), or when the existing session is
ASN-locked to another network. The setting's description, the error
message and the new doc all say so.

## Testing

- `tests/prowlarr/test_mam_enrichment.py` (new, 19 tests): parsing
(narrator dedupe, multiple series, missing numbers, malformed JSON, tag
bitrate), lookup (stops once all IDs are found, cache, 403 and
connection errors return empty, expired deadline skips the request),
only MAM releases enriched, Torznab bitrate mapping, column config with
and without a MAM ID for audiobook and ebook, toggles, grid-track
removal, and gates not serialized.
- `tests/core/test_admin_users_api.py`: the curated search-preference
key list now includes the three toggles.
- Full `pytest -m "not integration and not e2e"` compared with an
upstream `main` worktree on the same machine: no new failures. The
remaining ~115 failures on both are Windows-only (tor/entrypoint shell
tests, path separators).
- `ruff check` / `ruff format --check` / `basedpyright` (0 errors) /
`vulture` on touched files; frontend `tsc --noEmit`, `oxlint`, `oxfmt
--check`, `vitest` (201 passed).
- Manually verified with a real MAM account on a Docker build of this
branch: the test button, then narrator, series and bitrate on
MyAnonamouse audiobook results.

No behavior change unless `PROWLARR_MAM_ID` is set, apart from the
bitrate toggle on AudiobookBay (default on, same as today).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
This commit is contained in:
adman234
2026-09-25 19:17:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 CaliBrain
parent bb430dfdbc
commit a05e002305
15 changed files with 1033 additions and 36 deletions
+95 -22
View File
@@ -17,6 +17,7 @@ This document lists all configuration options that can be set via environment va
- [Prowlarr](#prowlarr)
- [Newznab](#newznab)
- [AudiobookBay](#audiobookbay)
- [Libgen Search](#libgen-search)
- [IRC](#irc)
- [Download Clients](#download-clients)
- [Metadata Providers](#metadata-providers)
@@ -256,7 +257,6 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar` |
| `BOOK_LANGUAGE` | Default language filter for searches. | string (comma-separated) | `en` |
<details>
<summary>Detailed descriptions</summary>
@@ -324,6 +324,9 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a
| `METADATA_PROVIDER_COMBINED` | Metadata provider for combined mode searches. Uses the book provider if not set. | string (choice) | _empty string_ |
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. Leave unset to use the first available source. | string (choice) | _empty string_ |
| `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ |
| `SHOW_SERIES_COLUMN` | Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. | boolean | `true` |
| `SHOW_NARRATOR_COLUMN` | Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. | boolean | `true` |
| `SHOW_BITRATE_COLUMN` | Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr results it needs a MAM session ID and is read from the MyAnonamouse uploader's tags, so some releases won't have one. Other Prowlarr indexers fill it only if they report a bitrate attribute, which most don't. | boolean | `true` |
<details>
<summary>Detailed descriptions</summary>
@@ -364,8 +367,8 @@ Which tab the search page opens on. Users can override this for their own accoun
Default sort order for search results.
- **Type:** string (choice)
- **Default:** `relevance`
- **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced))
- **Default:** _empty string_
- **Options:** `""` (Most downloads), `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced))
#### `SHOW_RELEASE_SOURCE_LINKS`
@@ -444,6 +447,33 @@ The release source tab to open by default in the release modal for audiobooks. U
- **Default:** _empty string_
- **Options:** `""` (Use book release source)
#### `SHOW_SERIES_COLUMN`
**Show Series Column**
Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings.
- **Type:** boolean
- **Default:** `true`
#### `SHOW_NARRATOR_COLUMN`
**Show Narrator Column**
Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings.
- **Type:** boolean
- **Default:** `true`
#### `SHOW_BITRATE_COLUMN`
**Show Bitrate Column**
Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr results it needs a MAM session ID and is read from the MyAnonamouse uploader's tags, so some releases won't have one. Other Prowlarr indexers fill it only if they report a bitrate attribute, which most don't.
- **Type:** boolean
- **Default:** `true`
</details>
## Downloads
@@ -779,7 +809,6 @@ Automatically open the downloads sidebar when a new download is queued.
Automatically download completed files to your browser for the selected content types.
- **Type:** string (comma-separated)
- **Default:** _empty list_
#### `MAX_CONCURRENT_DOWNLOADS`
@@ -813,8 +842,8 @@ How long to keep completed/failed downloads in the queue display.
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
| `PROXY_AUTH_DEFAULT_ROLE` | Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one. | string (choice) | `user` |
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
| `PROXY_AUTH_DEFAULT_ROLE` | Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one. | string (choice) | `user` |
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
| `OIDC_CLIENT_SECRET` | OAuth2 client secret from your identity provider. | string (secret) | _none_ |
@@ -873,6 +902,7 @@ Optional: users in this group are treated as admins. Leave blank to skip group-b
- **Type:** string
- **Default:** _empty string_
#### `PROXY_AUTH_DEFAULT_ROLE`
**Proxy Auth Default Role**
@@ -883,7 +913,6 @@ Role for users the proxy authenticates for the first time when no admin group is
- **Default:** `user`
- **Options:** `user` (User), `admin` (Admin)
#### `OIDC_DISCOVERY_URL`
**Discovery URL**
@@ -1269,6 +1298,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
| `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` |
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
| `PROWLARR_MAM_ID` | The mam_id value MyAnonamouse shows when you create a session. MAM locks each session to one IP or ASN, so reusing Prowlarr's or another client's session often fails with a 403: you will likely need a separate session for Shelfmark if it reaches MAM from another IP (different host, VPN or proxy) or the existing session is ASN-locked to another network. Leave empty to turn enrichment off. | string (secret) | _none_ |
<details>
<summary>Detailed descriptions</summary>
@@ -1348,6 +1378,15 @@ Apply per-indexer seed time and ratio preferences from Prowlarr when sending tor
- **Type:** boolean
- **Default:** `false`
#### `PROWLARR_MAM_ID`
**MAM Session ID**
The mam_id value MyAnonamouse shows when you create a session. MAM locks each session to one IP or ASN, so reusing Prowlarr's or another client's session often fails with a 403: you will likely need a separate session for Shelfmark if it reaches MAM from another IP (different host, VPN or proxy) or the existing session is ASN-locked to another network. Leave empty to turn enrichment off.
- **Type:** string (secret)
- **Default:** _none_
</details>
## Newznab
@@ -1355,9 +1394,9 @@ Apply per-indexer seed time and ratio preferences from Prowlarr when sending tor
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` |
| `NEWZNAB_INDEXERS` | Named Newznab connections. Each row accepts `name`, `url`, and `api_key`. | JSON array | `[]` |
| `NEWZNAB_URL` | Legacy single-indexer URL, used when `NEWZNAB_INDEXERS` is empty | string | _none_ |
| `NEWZNAB_API_KEY` | Legacy single-indexer API key | string (secret) | _none_ |
| `NEWZNAB_INDEXERS` | Add each Newznab-compatible indexer separately. The configured name is shown beside every result from that indexer. | string | _empty list_ |
| `NEWZNAB_URL` | Used only when the named indexer list is empty | string | _none_ |
| `NEWZNAB_API_KEY` | Used only with the legacy Newznab URL | string (secret) | _none_ |
| `NEWZNAB_EBOOK_CATEGORIES` | Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000. | string (comma-separated) | `7000` |
| `NEWZNAB_AUDIOBOOK_CATEGORIES` | Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030. | string (comma-separated) | `3030` |
| `NEWZNAB_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
@@ -1378,23 +1417,16 @@ Enable searching for books via a Newznab-compatible indexer
**Named Indexers**
Configure multiple named Newznab-compatible indexers. The name is shown beside each search result. For environment-based configuration, provide a JSON array:
Add each Newznab-compatible indexer separately. The configured name is shown beside every result from that indexer.
```json
[
{"name":"NZBGeek","url":"https://api.nzbgeek.info","api_key":"..."},
{"name":"DrunkenSlug","url":"https://drunkenslug.com","api_key":"..."}
]
```
- **Type:** JSON array
- **Default:** `[]`
- **Type:** string
- **Default:** _empty list_
#### `NEWZNAB_URL`
**Legacy Newznab URL**
Single-indexer fallback used only when `NEWZNAB_INDEXERS` is empty.
Used only when the named indexer list is empty
- **Type:** string
- **Default:** _none_
@@ -1403,7 +1435,7 @@ Single-indexer fallback used only when `NEWZNAB_INDEXERS` is empty.
**Legacy API Key**
API key for the legacy Newznab URL.
Used only with the legacy Newznab URL
- **Type:** string (secret)
- **Default:** _none_
@@ -1500,6 +1532,37 @@ Delay between requests in seconds to avoid rate limiting (0-10).
</details>
## Libgen Search
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `LIBGEN_SEARCH_ENABLED` | Search the Libgen catalogue directly, including CBZ/CBR comics and manga that Anna's Archive does not index. Uses the Libgen mirrors configured under Mirrors for both search and download. | boolean | `false` |
| `LIBGEN_SEARCH_MAX_RESULTS` | Maximum number of results to request per search (1-100). | number | `25` |
<details>
<summary>Detailed descriptions</summary>
#### `LIBGEN_SEARCH_ENABLED`
**Enable Libgen Search**
Search the Libgen catalogue directly, including CBZ/CBR comics and manga that Anna's Archive does not index. Uses the Libgen mirrors configured under Mirrors for both search and download.
- **Type:** boolean
- **Default:** `false`
#### `LIBGEN_SEARCH_MAX_RESULTS`
**Max Results**
Maximum number of results to request per search (1-100).
- **Type:** number
- **Default:** `25`
- **Constraints:** min: 1, max: 100
</details>
## IRC
| Variable | Description | Type | Default |
@@ -1610,6 +1673,7 @@ How long to keep cached search results before they expire.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ |
| `BLACKHOLE_DIRECTORY` | Directory where Shelfmark saves .torrent files for another downloader | string | _none_ |
| `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ |
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
| `TORBOX_API_KEY` | TorBox API Key from your TorBox account settings | string (secret) | _none_ |
@@ -1664,7 +1728,16 @@ Choose which torrent client to use
- **Type:** string (choice)
- **Default:** _empty string_
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
- **Options:** `""` (None), `alldebrid` (AllDebrid), `blackhole` (Blackhole), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
#### `BLACKHOLE_DIRECTORY`
**Blackhole Directory**
Directory where Shelfmark saves .torrent files for another downloader
- **Type:** string
- **Default:** _none_
#### `ALLDEBRID_API_KEY`
+1
View File
@@ -17,6 +17,7 @@ Use the guides below to set up the app, connect your library tools, and understa
- [OIDC](oidc.md)
- [API Access](api-access.md)
- [URL Search Parameters](url-search-parameters.md)
- [MyAnonamouse Enrichment](myanonamouse-enrichment.md)
- [Library Check](library-check.md)
- [Custom Scripts](custom-scripts.md)
+38
View File
@@ -0,0 +1,38 @@
# MyAnonamouse Enrichment
Prowlarr's MyAnonamouse indexer keeps the title, author, language and file type of each result but drops the narrator and series, and Torznab has no field to carry them. With a MyAnonamouse session ID, Shelfmark looks those details up directly from MyAnonamouse and adds them to the Prowlarr release list.
## What you get
| Column | Shown for | Source |
|--------|-----------|--------|
| Series | Books and audiobooks | MAM series info, e.g. `The Sun Eater #1` |
| Narrator | Audiobooks | MAM narrator info |
| Bitrate | Audiobooks | Parsed from the uploader's tags (e.g. `64 kbps`), so some releases have none |
Only MyAnonamouse results are enriched. Other Prowlarr indexers fill the bitrate column only if they report a Torznab `bitrate` attribute, which most don't. AudiobookBay's bitrate column is unaffected and follows the same toggle.
## Setup
1. On MyAnonamouse, open **Preferences > Security** and create a session for Shelfmark. Lock it to the public IP (or ASN) Shelfmark connects from.
2. In Shelfmark, open **Settings > Prowlarr > MyAnonamouse Enrichment**, paste the `mam_id` value into **MAM Session ID** (or set `PROWLARR_MAM_ID`), and click **Test MAM Session**.
3. Choose which columns appear under **Settings > Search Mode > Release List Columns** (`SHOW_SERIES_COLUMN`, `SHOW_NARRATOR_COLUMN`, `SHOW_BITRATE_COLUMN`). Users can override these for their own account.
## Use a session made for Shelfmark
MyAnonamouse locks each session to one IP or ASN. Reusing the session Prowlarr (or a seedbox script) uses often fails with **403**:
- If Shelfmark reaches MAM from a different IP than that session was created for (another host, a VPN container, or a proxy set under **Settings > Network**), MAM rejects it.
- If the existing session is ASN-locked to another network, it will not work from Shelfmark's network either.
In either case, create a separate session for Shelfmark. To see the IP Shelfmark connects from:
```bash
docker exec shelfmark curl -s https://api.ipify.org
```
Compare it with the IP listed next to the session on MAM's security page.
## How it works
After a Prowlarr search, Shelfmark sends the same query text to MAM's JSON search API (normally one request) and matches torrents back to Prowlarr's results by their MAM torrent ID. Lookups are cached for an hour, stay inside the Prowlarr search time budget, and never fail the search: if MAM errors or rejects the session, the release list simply shows without the extra columns filled in.
+45
View File
@@ -612,6 +612,51 @@ def search_mode_settings() -> list[SettingsField]:
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
HeadingField(
key="release_columns_heading",
title="Release List Columns",
description=(
"Optional columns in the release list. Rows stay blank where a source "
"doesn't provide the detail."
),
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
CheckboxField(
key="SHOW_SERIES_COLUMN",
label="Show Series Column",
description=(
"Series name and number, for books and audiobooks. Filled in for "
"MyAnonamouse results from Prowlarr when a MAM session ID is set in the "
"Prowlarr settings."
),
default=True,
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
CheckboxField(
key="SHOW_NARRATOR_COLUMN",
label="Show Narrator Column",
description=(
"Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a "
"MAM session ID is set in the Prowlarr settings."
),
default=True,
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
CheckboxField(
key="SHOW_BITRATE_COLUMN",
label="Show Bitrate Column",
description=(
"Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr "
"results it needs a MAM session ID and is read from the MyAnonamouse "
"uploader's tags, so some releases won't have one. Other Prowlarr indexers "
"fill it only if they report a bitrate attribute, which most don't."
),
default=True,
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
]
+9 -8
View File
@@ -78,13 +78,19 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = {
"METADATA_PROVIDER_AUDIOBOOK",
"METADATA_PROVIDER_COMBINED",
}
_SEARCH_PREFERENCE_BOOLEAN_KEYS = {
"SHOW_COMBINED_SELECTOR",
"FORCE_COMBINED_SEARCH",
"SHOW_SERIES_COLUMN",
"SHOW_NARRATOR_COLUMN",
"SHOW_BITRATE_COLUMN",
}
SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"SEARCH_MODE",
"BOOK_LANGUAGE",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_COMBINED_SELECTOR",
"FORCE_COMBINED_SEARCH",
*_SEARCH_PREFERENCE_BOOLEAN_KEYS,
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
@@ -254,12 +260,7 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
)
return normalized_value, None
if key == "SHOW_COMBINED_SELECTOR":
if isinstance(value, bool):
return value, None
return bool(value), None
if key == "FORCE_COMBINED_SEARCH":
if key in _SEARCH_PREFERENCE_BOOLEAN_KEYS:
if isinstance(value, bool):
return value, None
return bool(value), None
+10 -1
View File
@@ -2972,6 +2972,7 @@ def api_releases() -> Response | tuple[Response, int]:
is_provider_registered,
)
from shelfmark.release_sources import (
apply_column_visibility,
browse_record_to_book_metadata,
get_source,
list_available_sources,
@@ -3144,7 +3145,15 @@ def api_releases() -> Response | tuple[Response, int]:
if sources_to_search and sources_to_search[0] in source_instances:
try:
first_source = source_instances[sources_to_search[0]]
column_config = serialize_column_config(first_source.get_column_config())
column_config = serialize_column_config(
apply_column_visibility(
first_source.get_column_config(),
content_type=normalize_content_type(content_type),
is_setting_enabled=lambda key: bool(
app_config.get(key, True, user_id=db_user_id)
),
)
)
except _OPERATIONAL_ERRORS as e:
logger.warning("Failed to get column config: %s", e)
+58 -1
View File
@@ -1,7 +1,7 @@
"""Release source plugin system - base classes and registry."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from enum import StrEnum
from importlib import import_module
from typing import TYPE_CHECKING, Any, ClassVar, Literal
@@ -139,6 +139,9 @@ class ColumnSchema:
uppercase: bool = False # Force uppercase display
sortable: bool = False # Show in sort dropdown (opt-in)
sort_key: str | None = None # Field to sort by (defaults to `key` if None)
# Optional visibility gates, applied by apply_column_visibility() before serialization.
setting_key: str | None = None # Boolean setting that shows/hides this column
content_types: tuple[str, ...] | None = None # Only show for these ("ebook", "audiobook")
class LeadingCellType(StrEnum):
@@ -200,6 +203,60 @@ class ReleaseColumnConfig:
)
def _split_grid_template(template: str) -> list[str]:
"""Split a grid-template-columns string into tracks, keeping minmax(a, b) whole."""
tracks: list[str] = []
current = ""
depth = 0
for char in template:
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if char.isspace() and depth == 0:
if current:
tracks.append(current)
current = ""
continue
current += char
if current:
tracks.append(current)
return tracks
def apply_column_visibility(
config: ReleaseColumnConfig,
*,
content_type: str,
is_setting_enabled: Callable[[str], bool],
) -> ReleaseColumnConfig:
"""Drop columns gated off by content type or a disabled setting.
The grid template's first track is the title; the rest line up with columns, so
removing a column removes its track too. A template that doesn't line up is
rebuilt from the remaining columns' widths.
"""
keep = [
(column.content_types is None or content_type in column.content_types)
and (column.setting_key is None or is_setting_enabled(column.setting_key))
for column in config.columns
]
if all(keep):
return config
columns = [column for column, visible in zip(config.columns, keep, strict=True) if visible]
tracks = _split_grid_template(config.grid_template)
if len(tracks) == len(config.columns) + 1:
grid_tracks = [tracks[0]] + [
track for track, visible in zip(tracks[1:], keep, strict=True) if visible
]
else:
grid_tracks = [tracks[0] if tracks else "minmax(0,2fr)"] + [c.width for c in columns]
return replace(config, columns=columns, grid_template=" ".join(grid_tracks))
def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]:
"""Serialize column configuration for API response."""
result: dict[str, Any] = {
@@ -350,6 +350,7 @@ class AudiobookBaySource(ReleaseSource):
fallback="",
sortable=True,
sort_key="extra.bitrate_value",
setting_key="SHOW_BITRATE_COLUMN",
),
ColumnSchema(
key="size",
+276
View File
@@ -0,0 +1,276 @@
"""MyAnonamouse enrichment for Prowlarr results.
Prowlarr's MyAnonamouse indexer keeps the title, author, language and filetype
of each torrent but drops the narrator and series MAM returns alongside them, and
Torznab has no field to carry them. With the user's MAM session ID (``mam_id``)
Shelfmark runs the same text search against MAM's JSON API and matches the
torrents back to Prowlarr's results by their MAM torrent ID.
"""
import json
import re
import time
from dataclasses import dataclass
from threading import Lock
from typing import Any
from urllib.parse import urlsplit
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.download.network import get_proxies, get_ssl_verify
from shelfmark.release_sources.prowlarr.utils import coerce_int_like
logger = setup_logger(__name__)
DEFAULT_MAM_BASE_URL = "https://www.myanonamouse.net"
_SEARCH_PATH = "/tor/js/loadSearchJSONbasic.php"
_USER_PATH = "/jsonLoad.php"
_REQUEST_TIMEOUT_SECONDS = 15
_RESULTS_PER_PAGE = 100
_MAX_SEARCHES = 3
_CACHE_TTL_SECONDS = 3600
_HTTP_FORBIDDEN = 403
# Prowlarr sets both infoUrl and guid to "{BaseUrl}t/{id}".
_TORRENT_URL_RE = re.compile(r"^https?://[^/]*myanonamouse\.net/t/(\d+)", re.IGNORECASE)
# Uploaders put the bitrate in the free-text tags: "64 kbps", "128kbps", "64 kb/s".
_BITRATE_RE = re.compile(r"\b(\d{2,4}(?:\.\d+)?)\s*k(?:bps|b/s|bit/s)\b", re.IGNORECASE)
_MAM_REQUEST_ERRORS = (requests.exceptions.RequestException, ValueError)
@dataclass(frozen=True)
class MamTorrentDetails:
"""The fields Prowlarr drops from a MyAnonamouse search result."""
narrator: str | None = None
series: str | None = None
bitrate: str | None = None
bitrate_kbps: int | None = None
_cache: dict[int, tuple[MamTorrentDetails, float]] = {}
_cache_lock = Lock()
def mam_torrent_id(url: object) -> int | None:
"""Return the MAM torrent ID from a Prowlarr infoUrl/guid, or None for other trackers."""
if not isinstance(url, str):
return None
match = _TORRENT_URL_RE.match(url.strip())
return int(match.group(1)) if match else None
def mam_base_url(url: object) -> str:
"""Return the MAM origin a Prowlarr result points at (Prowlarr's configured base URL)."""
if isinstance(url, str) and mam_torrent_id(url) is not None:
parts = urlsplit(url.strip())
return f"{parts.scheme}://{parts.netloc}"
return DEFAULT_MAM_BASE_URL
def _decode_info(raw: object) -> dict[str, Any]:
"""MAM returns author/narrator/series info as a JSON-encoded object string."""
if isinstance(raw, dict):
return raw
if not isinstance(raw, str) or not raw.strip():
return {}
try:
decoded = json.loads(raw)
except ValueError:
return {}
return decoded if isinstance(decoded, dict) else {}
def _parse_names(raw: object) -> str | None:
names = [str(v).strip() for v in _decode_info(raw).values() if isinstance(v, str)]
names = [n for n in names if n]
return ", ".join(dict.fromkeys(names)) or None
def _parse_series(raw: object) -> str | None:
"""Format series_info ({"id": ["Name", "1"]}) as "Name #1"."""
entries: list[str] = []
for value in _decode_info(raw).values():
name: str = ""
number: str = ""
if isinstance(value, list | tuple) and value:
name = str(value[0] or "").strip()
if len(value) > 1 and value[1] not in (None, ""):
number = str(value[1]).strip()
elif isinstance(value, str):
name = value.strip()
if not name:
continue
entries.append(f"{name} #{number}" if number else name)
return ", ".join(dict.fromkeys(entries)) or None
def _parse_bitrate(tags: object) -> tuple[str | None, int | None]:
if not isinstance(tags, str):
return None, None
match = _BITRATE_RE.search(tags)
if not match:
return None, None
kbps = round(float(match.group(1)))
return f"{kbps} Kbps", kbps
def parse_torrent_details(item: dict[str, Any]) -> MamTorrentDetails:
"""Pull narrator, series and bitrate out of one MAM search result."""
bitrate, bitrate_kbps = _parse_bitrate(item.get("tags"))
return MamTorrentDetails(
narrator=_parse_names(item.get("narrator_info")),
series=_parse_series(item.get("series_info")),
bitrate=bitrate,
bitrate_kbps=bitrate_kbps,
)
class MamAuthError(Exception):
"""MAM rejected the session ID."""
class MamClient:
"""Minimal MyAnonamouse JSON API client authenticated by a ``mam_id`` cookie."""
def __init__(self, mam_id: str, base_url: str = DEFAULT_MAM_BASE_URL) -> None:
"""Create a client for the given session ID and MAM origin."""
self.base_url = base_url.rstrip("/")
self._session = requests.Session()
self._session.cookies.set("mam_id", mam_id.strip())
self._session.headers.update({"Accept": "application/json"})
def _get(self, path: str, params: dict[str, str] | None = None) -> object:
url = self.base_url + path
response = self._session.get(
url,
params=params,
timeout=_REQUEST_TIMEOUT_SECONDS,
proxies=get_proxies(url),
verify=get_ssl_verify(url),
)
if response.status_code == _HTTP_FORBIDDEN:
# MAM explains itself in the body (bad cookie, IP/ASN mismatch, ...).
reply = " ".join(response.text.split())[:200]
msg = (
"MyAnonamouse rejected the session ID (403). Sessions only work from the "
"IP/ASN they are locked to, so check Shelfmark reaches MAM from that address"
+ (f". MAM said: {reply}" if reply else "")
)
raise MamAuthError(msg)
response.raise_for_status()
return response.json()
def get_username(self) -> str | None:
"""Return the account name the session belongs to (used by Test Connection)."""
data = self._get(_USER_PATH)
if not isinstance(data, dict):
return None
username = data.get("username")
return str(username) if username else None
def search(self, text: str) -> list[dict[str, Any]]:
"""Search torrents across all categories by title, author, narrator and series."""
params = {
"tor[text]": text,
"tor[searchType]": "all",
"tor[searchIn]": "torrents",
"tor[srchIn][title]": "true",
"tor[srchIn][author]": "true",
"tor[srchIn][narrator]": "true",
"tor[srchIn][series]": "true",
"tor[cat][]": "0",
"tor[sortType]": "default",
"tor[startNumber]": "0",
"perpage": str(_RESULTS_PER_PAGE),
}
data = self._get(_SEARCH_PATH, params)
if not isinstance(data, dict):
return []
# An empty search answers {"error": "Nothing returned, out of N"}.
items = data.get("data")
if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
def _cached(torrent_id: int) -> MamTorrentDetails | None:
with _cache_lock:
entry = _cache.get(torrent_id)
if entry is None:
return None
details, cached_at = entry
if time.time() - cached_at > _CACHE_TTL_SECONDS:
del _cache[torrent_id]
return None
return details
def _store(details_by_id: dict[int, MamTorrentDetails]) -> None:
now = time.time()
with _cache_lock:
for torrent_id, details in details_by_id.items():
_cache[torrent_id] = (details, now)
def lookup_torrent_details(
mam_id: str,
torrent_ids: set[int],
queries: list[str],
*,
base_url: str = DEFAULT_MAM_BASE_URL,
deadline: float | None = None,
) -> dict[int, MamTorrentDetails]:
"""Fetch narrator/series/bitrate for the given MAM torrent IDs (best effort).
Reruns the queries Prowlarr was sent until every ID is found, capped at a few
requests. Any failure is logged and returns what was found so far; enrichment
must never break the Prowlarr search it decorates.
"""
found: dict[int, MamTorrentDetails] = {}
for torrent_id in torrent_ids:
details = _cached(torrent_id)
if details is not None:
found[torrent_id] = details
missing = torrent_ids - found.keys()
if not missing or not mam_id.strip():
return found
client = MamClient(mam_id, base_url)
searched = 0
for query in dict.fromkeys(q.strip() for q in queries if q and q.strip()):
if not missing or searched >= _MAX_SEARCHES:
break
if deadline is not None and time.monotonic() > deadline:
logger.debug("MAM enrichment: search budget spent, %s torrent(s) left", len(missing))
break
searched += 1
try:
items = client.search(query)
except MamAuthError as e:
logger.warning("MAM enrichment disabled for this search: %s", e)
break
except _MAM_REQUEST_ERRORS as e:
logger.warning("MAM enrichment search failed for '%s': %s", query, e)
break
fetched: dict[int, MamTorrentDetails] = {}
for item in items:
torrent_id = coerce_int_like(item.get("id"))
if torrent_id is not None:
fetched[torrent_id] = parse_torrent_details(item)
_store(fetched)
for torrent_id in missing & fetched.keys():
found[torrent_id] = fetched[torrent_id]
missing -= fetched.keys()
logger.debug(
"MAM enrichment: %s of %s torrent(s) matched in %s search(es)",
len(found),
len(torrent_ids),
searched,
)
return found
@@ -133,6 +133,27 @@ def _test_prowlarr_connection(current_values: dict[str, Any] | None = None) -> d
return {"success": success, "message": message}
def _test_mam_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Check the MAM session ID by asking MAM which account it belongs to."""
from shelfmark.release_sources.prowlarr.mam import MamAuthError, MamClient
current_values = current_values or {}
mam_id = _resolve_setting_text(current_values, "PROWLARR_MAM_ID")
if not mam_id:
return {"success": False, "message": "MAM session ID is required"}
try:
username = MamClient(mam_id).get_username()
except MamAuthError as e:
return {"success": False, "message": str(e)}
except _PROWLARR_SETTINGS_ERRORS as e:
return {"success": False, "message": f"Connection failed: {e!s}"}
if not username:
return {"success": False, "message": "MyAnonamouse did not return an account for this ID"}
return {"success": True, "message": f"Connected to MyAnonamouse as {username}"}
# ==================== Configuration Tab ====================
@@ -229,4 +250,39 @@ def prowlarr_config_settings() -> list[SettingsField]:
description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client",
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
HeadingField(
key="prowlarr_mam_heading",
title="MyAnonamouse Enrichment",
description=(
"Prowlarr drops the narrator and series from MyAnonamouse results. With a MAM "
"session ID, Shelfmark looks them up (plus bitrate, from the uploader's tags) "
"directly from MyAnonamouse. Create the session under Preferences > Security "
"on MyAnonamouse, locked to the IP or ASN Shelfmark connects from. Choose "
"which columns show under Search Mode > Release List Columns."
),
link_url="https://www.myanonamouse.net/preferences/index.php?view=security",
link_text="MAM security settings",
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
PasswordField(
key="PROWLARR_MAM_ID",
label="MAM Session ID",
description=(
"The mam_id value MyAnonamouse shows when you create a session. MAM locks "
"each session to one IP or ASN, so reusing Prowlarr's or another client's "
"session often fails with a 403: you will likely need a separate session "
"for Shelfmark if it reaches MAM from another IP (different host, VPN or "
"proxy) or the existing session is ASN-locked to another network. Leave "
"empty to turn enrichment off."
),
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
ActionButton(
key="test_prowlarr_mam",
label="Test MAM Session",
description="Check that MyAnonamouse accepts the session ID",
style="primary",
callback=_test_mam_connection,
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
]
+141 -2
View File
@@ -41,6 +41,11 @@ from shelfmark.release_sources.prowlarr.api import (
ProwlarrSearchError,
)
from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.mam import (
lookup_torrent_details,
mam_base_url,
mam_torrent_id,
)
from shelfmark.release_sources.prowlarr.utils import (
build_source_id,
coerce_float_like,
@@ -62,6 +67,63 @@ _UNRANKED_INDEXER_RANK = 51
# is not guaranteed), so include RequestException explicitly.
_PROWLARR_REQUEST_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
_BITRATE_NUMBER_RE = re.compile(r"(\d+(?:\.\d+)?)")
def _get_mam_session_id() -> str:
return normalize_optional_text(config.get("PROWLARR_MAM_ID", "")) or ""
def _parse_torznab_bitrate(value: object) -> int | None:
"""Read a Torznab "bitrate" attribute ("320" or "320 kbps") as whole Kbps."""
if value is None:
return None
match = _BITRATE_NUMBER_RE.search(str(value))
if not match:
return None
kbps = round(float(match.group(1)))
return kbps or None
def _enrich_mam_releases(
releases: list[Release],
mam_id: str,
queries: list[str],
deadline: float | None,
) -> None:
"""Fill narrator/series/bitrate on MyAnonamouse releases from MAM's own API."""
ids_by_source_id: dict[str, int] = {}
base_url: str | None = None
for release in releases:
torrent_id = mam_torrent_id(release.info_url)
if torrent_id is None:
continue
ids_by_source_id[release.source_id] = torrent_id
base_url = base_url or mam_base_url(release.info_url)
if not ids_by_source_id or base_url is None:
return
details_by_id = lookup_torrent_details(
mam_id,
set(ids_by_source_id.values()),
queries,
base_url=base_url,
deadline=deadline,
)
for release in releases:
torrent_id = ids_by_source_id.get(release.source_id)
details = details_by_id.get(torrent_id) if torrent_id is not None else None
if details is None:
continue
if details.narrator:
release.extra["narrator"] = details.narrator
if details.series:
release.extra["series"] = details.series
if details.bitrate and not release.extra.get("bitrate"):
release.extra["bitrate"] = details.bitrate
release.extra["bitrate_value"] = details.bitrate_kbps
def _raise_timeout_error(message: str) -> NoReturn:
raise TimeoutError(message)
@@ -559,6 +621,14 @@ def _prowlarr_result_to_release(
if any(flag.lower() in {"freeleech", "fl"} for flag in indexer_flags):
is_freeleech = True
# Few indexers send a Torznab bitrate; MyAnonamouse's comes from MAM enrichment instead.
torznab_attrs = result.get("torznabAttrs")
bitrate_kbps = (
_parse_torznab_bitrate(torznab_attrs.get("bitrate"))
if isinstance(torznab_attrs, dict)
else None
)
is_vip = "[vip]" in str(raw_title).lower()
if is_vip:
add_indexer_flag("VIP")
@@ -607,8 +677,10 @@ def _prowlarr_result_to_release(
# Format tokens the indexer declared but Shelfmark can't process (e.g. a MAM
# "[ENG / AVI]"). Lets the UI warn instead of showing a bare content icon.
"unrecognized_formats": unrecognized_formats or None,
"bitrate": f"{bitrate_kbps} Kbps" if bitrate_kbps else None,
"bitrate_value": bitrate_kbps,
# Raw torznab attributes for rich tooltips (enriched indexers)
"torznab_attrs": result.get("torznabAttrs"),
"torznab_attrs": torznab_attrs,
},
)
@@ -717,8 +789,58 @@ class ProwlarrSource(ReleaseSource):
except _PROWLARR_SOURCE_ERRORS as e:
logger.warning("Failed to fetch indexer list for column config: %s", e)
# Series/narrator/bitrate only exist once MAM enrichment has a session ID;
# without it they would be empty for every row. The show/hide toggles and the
# audiobook-only gates are applied later by apply_column_visibility().
mam_columns: list[ColumnSchema] = []
mam_tracks = ""
mam_bitrate_columns: list[ColumnSchema] = []
mam_bitrate_track = ""
if _get_mam_session_id():
mam_columns = [
ColumnSchema(
key="extra.series",
label="Series",
render_type=ColumnRenderType.TEXT,
align=ColumnAlign.LEFT,
width="minmax(90px, 1fr)",
hide_mobile=False,
fallback="",
setting_key="SHOW_SERIES_COLUMN",
),
ColumnSchema(
key="extra.narrator",
label="Narrator",
render_type=ColumnRenderType.TEXT,
align=ColumnAlign.LEFT,
width="minmax(90px, 1fr)",
hide_mobile=False,
fallback="",
setting_key="SHOW_NARRATOR_COLUMN",
content_types=("audiobook",),
),
]
mam_tracks = " minmax(90px,1fr) minmax(90px,1fr)"
mam_bitrate_columns = [
ColumnSchema(
key="extra.bitrate",
label="Bitrate",
render_type=ColumnRenderType.NUMBER,
align=ColumnAlign.CENTER,
width="72px",
hide_mobile=False,
fallback="",
sortable=True,
sort_key="extra.bitrate_value",
setting_key="SHOW_BITRATE_COLUMN",
content_types=("audiobook",),
),
]
mam_bitrate_track = " 72px"
return ReleaseColumnConfig(
columns=[
*mam_columns,
ColumnSchema(
key="indexer",
label="Indexer",
@@ -761,6 +883,7 @@ class ProwlarrSource(ReleaseSource):
uppercase=True,
fallback="",
),
*mam_bitrate_columns,
ColumnSchema(
key="size",
label="Size",
@@ -780,7 +903,10 @@ class ProwlarrSource(ReleaseSource):
default_direction="asc",
),
],
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
grid_template=(
f"minmax(0,2fr){mam_tracks} minmax(140px,1fr) 50px 50px 90px"
f"{mam_bitrate_track} 80px"
),
leading_cell=LeadingCellConfig(
type=LeadingCellType.NONE
), # No leading cell for Prowlarr
@@ -1169,6 +1295,19 @@ class ProwlarrSource(ReleaseSource):
if is_enriched:
enriched_source_ids.add(release.source_id)
mam_session_id = _get_mam_session_id()
if mam_session_id:
try:
_enrich_mam_releases(
results,
mam_session_id,
[variant.title for variant in variants],
deadline,
)
except _PROWLARR_REQUEST_ERRORS:
# Enrichment only decorates rows; never let it lose the search.
logger.warning("MAM enrichment failed", exc_info=True)
# Indexer priority first: it is an explicit user preference. Author
# agreement then orders what one indexer returned, so the editions that
# match the requested author lead and the rest stay reachable below.
+5 -1
View File
@@ -255,6 +255,8 @@ export const ReleaseCell = ({
];
for (const attr of displayAttrs) {
// Already listed above from extra.bitrate (Prowlarr copies the attribute there).
if (attr.key === 'bitrate' && bitrate) continue;
const val = toStringValue(torznabAttrs[attr.key]);
if (val && val.trim()) {
rows.push({ label: attr.label, value: val.trim() });
@@ -626,7 +628,9 @@ export const ReleaseCell = ({
title={isOnline ? 'Online' : 'Offline'}
/>
)}
{displayValue}
<span className="truncate" title={displayValue || undefined}>
{displayValue}
</span>
</div>
);
}
+6 -1
View File
@@ -552,7 +552,7 @@ const ReleaseRow = ({
</p>
{/* Plugin-provided info line (format, size, indexer, seeders, etc.) */}
{mobileColumns.length > 0 && (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-zinc-500 dark:text-zinc-400">
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[10px] text-zinc-500 dark:text-zinc-400">
{(() => {
// Pre-filter columns that will render content to avoid orphan dots
const columnsWithContent = mobileColumns.filter((col) => {
@@ -579,6 +579,11 @@ const ReleaseRow = ({
}
}
if (col.render_type === 'text' || col.render_type === 'number') {
// Optional detail columns (series, narrator, bitrate) are often blank
return value.trim() !== '';
}
return true;
});
return columnsWithContent.map((col, idx) => (
+3
View File
@@ -1395,6 +1395,9 @@ class TestAdminSearchPreferences:
"METADATA_PROVIDER_COMBINED",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_SERIES_COLUMN",
"SHOW_NARRATOR_COLUMN",
"SHOW_BITRATE_COLUMN",
]
field_keys = [field["key"] for field in data["fields"]]
+289
View File
@@ -0,0 +1,289 @@
"""Tests for MyAnonamouse enrichment of Prowlarr results (narrator, series, bitrate)."""
import json
import pytest
import requests
import shelfmark.release_sources.prowlarr.mam as mam
import shelfmark.release_sources.prowlarr.source as prowlarr_source
from shelfmark.release_sources import (
ColumnSchema,
ReleaseColumnConfig,
apply_column_visibility,
serialize_column_config,
)
from shelfmark.release_sources.prowlarr.mam import (
MamAuthError,
lookup_torrent_details,
mam_base_url,
mam_torrent_id,
parse_torrent_details,
)
from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_enrich_mam_releases,
_prowlarr_result_to_release,
)
@pytest.fixture(autouse=True)
def _clear_mam_cache():
mam._cache.clear()
yield
mam._cache.clear()
def _mam_item(torrent_id: int, **fields) -> dict:
item = {
"id": torrent_id,
"title": "Empire of Silence",
"author_info": json.dumps({"1": "Christopher Ruocchio"}),
}
item.update(fields)
return item
class TestParsing:
def test_torrent_id_from_prowlarr_info_url(self):
assert mam_torrent_id("https://www.myanonamouse.net/t/123456") == 123456
assert mam_torrent_id("https://cdn.myanonamouse.net/t/42") == 42
assert mam_torrent_id("https://example.org/t/123") is None
assert mam_torrent_id(None) is None
def test_base_url_follows_the_result(self):
assert mam_base_url("https://cdn.myanonamouse.net/t/42") == "https://cdn.myanonamouse.net"
assert mam_base_url("https://example.org/t/42") == mam.DEFAULT_MAM_BASE_URL
def test_parses_narrator_series_and_bitrate(self):
details = parse_torrent_details(
_mam_item(
1,
narrator_info=json.dumps({"7": "Samuel Roukin", "8": "Samuel Roukin"}),
series_info=json.dumps({"3": ["The Sun Eater", "1"]}),
tags="Unabridged | 64 kbps | Tantor",
)
)
assert details.narrator == "Samuel Roukin"
assert details.series == "The Sun Eater #1"
assert details.bitrate == "64 Kbps"
assert details.bitrate_kbps == 64
def test_multiple_series_and_missing_number(self):
details = parse_torrent_details(
_mam_item(1, series_info=json.dumps({"3": ["Saga", ""], "4": ["Arc", "2.5"]}))
)
assert details.series == "Saga, Arc #2.5"
def test_missing_or_malformed_fields_are_none(self):
details = parse_torrent_details(
_mam_item(1, narrator_info="not json", series_info="", tags="Unabridged")
)
assert details == mam.MamTorrentDetails()
class _FakeMamClient:
def __init__(self, items_by_query=None, error=None):
self.items_by_query = items_by_query or {}
self.error = error
self.queries: list[str] = []
def __call__(self, mam_id, base_url=mam.DEFAULT_MAM_BASE_URL):
self.mam_id = mam_id
self.base_url = base_url
return self
def search(self, text):
self.queries.append(text)
if self.error:
raise self.error
return self.items_by_query.get(text, [])
class TestLookup:
def test_stops_searching_once_every_id_is_found(self, monkeypatch):
fake = _FakeMamClient(
{"Empire of Silence": [_mam_item(1, narrator_info=json.dumps({"1": "Roukin"}))]}
)
monkeypatch.setattr(mam, "MamClient", fake)
found = lookup_torrent_details("session", {1}, ["Empire of Silence", "Other title"])
assert found[1].narrator == "Roukin"
assert fake.queries == ["Empire of Silence"]
def test_uses_cache_on_repeat(self, monkeypatch):
fake = _FakeMamClient({"q": [_mam_item(1, narrator_info=json.dumps({"1": "Roukin"}))]})
monkeypatch.setattr(mam, "MamClient", fake)
lookup_torrent_details("session", {1}, ["q"])
lookup_torrent_details("session", {1}, ["q"])
assert fake.queries == ["q"]
@pytest.mark.parametrize(
"error",
[MamAuthError("rejected"), requests.exceptions.ConnectionError("down")],
)
def test_failures_return_empty_instead_of_raising(self, monkeypatch, error):
fake = _FakeMamClient(error=error)
monkeypatch.setattr(mam, "MamClient", fake)
assert lookup_torrent_details("session", {1}, ["q", "r"]) == {}
assert fake.queries == ["q"]
def test_expired_deadline_skips_the_request(self, monkeypatch):
fake = _FakeMamClient()
monkeypatch.setattr(mam, "MamClient", fake)
assert lookup_torrent_details("session", {1}, ["q"], deadline=0.0) == {}
assert fake.queries == []
class TestReleaseEnrichment:
def _release(self, info_url: str, guid: str):
return _prowlarr_result_to_release(
{
"guid": guid,
"infoUrl": info_url,
"title": "Empire of Silence",
"indexer": "MyAnonamouse",
"indexerId": 1,
"protocol": "torrent",
"categories": [3030],
},
"audiobook",
)
def test_only_mam_releases_are_enriched(self, monkeypatch):
fake = _FakeMamClient(
{
"Empire of Silence": [
_mam_item(
99,
narrator_info=json.dumps({"1": "Samuel Roukin"}),
series_info=json.dumps({"2": ["The Sun Eater", "1"]}),
tags="64 kbps",
)
]
}
)
monkeypatch.setattr(mam, "MamClient", fake)
mam_release = self._release("https://www.myanonamouse.net/t/99", "mam-guid")
other_release = self._release("https://tracker.example/t/99", "other-guid")
_enrich_mam_releases(
[mam_release, other_release], "session", ["Empire of Silence"], deadline=None
)
assert mam_release.extra["narrator"] == "Samuel Roukin"
assert mam_release.extra["series"] == "The Sun Eater #1"
assert mam_release.extra["bitrate"] == "64 Kbps"
assert mam_release.extra["bitrate_value"] == 64
assert "narrator" not in other_release.extra
assert fake.base_url == "https://www.myanonamouse.net"
def test_torznab_bitrate_attribute_is_used_for_other_indexers(self):
release = _prowlarr_result_to_release(
{
"guid": "g",
"title": "Book",
"protocol": "torrent",
"torznabAttrs": {"bitrate": "320"},
},
"audiobook",
)
assert release.extra["bitrate"] == "320 Kbps"
assert release.extra["bitrate_value"] == 320
class TestColumnConfig:
def _source(self, monkeypatch, mam_id: str) -> ProwlarrSource:
monkeypatch.setattr(prowlarr_source, "_get_mam_session_id", lambda: mam_id)
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: None)
return source
def test_no_mam_columns_without_session_id(self, monkeypatch):
config = self._source(monkeypatch, "").get_column_config()
keys = [c.key for c in config.columns]
assert "extra.narrator" not in keys
assert "extra.series" not in keys
assert config.grid_template == "minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px"
def test_audiobook_shows_all_enabled_mam_columns(self, monkeypatch):
config = apply_column_visibility(
self._source(monkeypatch, "session").get_column_config(),
content_type="audiobook",
is_setting_enabled=lambda _key: True,
)
assert [c.key for c in config.columns][:3] == [
"extra.series",
"extra.narrator",
"indexer",
]
assert "extra.bitrate" in [c.key for c in config.columns]
assert config.grid_template == (
"minmax(0,2fr) minmax(90px,1fr) minmax(90px,1fr) minmax(140px,1fr) "
"50px 50px 90px 72px 80px"
)
def test_ebook_keeps_series_only(self, monkeypatch):
config = apply_column_visibility(
self._source(monkeypatch, "session").get_column_config(),
content_type="ebook",
is_setting_enabled=lambda _key: True,
)
keys = [c.key for c in config.columns]
assert "extra.series" in keys
assert "extra.narrator" not in keys
assert "extra.bitrate" not in keys
assert config.grid_template == (
"minmax(0,2fr) minmax(90px,1fr) minmax(140px,1fr) 50px 50px 90px 80px"
)
def test_toggles_hide_columns(self, monkeypatch):
config = apply_column_visibility(
self._source(monkeypatch, "session").get_column_config(),
content_type="audiobook",
is_setting_enabled=lambda key: key == "SHOW_NARRATOR_COLUMN",
)
keys = [c.key for c in config.columns]
assert "extra.narrator" in keys
assert "extra.series" not in keys
assert "extra.bitrate" not in keys
assert len(config.grid_template.split(" ")) == len(keys) + 1
class TestApplyColumnVisibility:
def test_unchanged_when_nothing_is_gated(self):
config = ReleaseColumnConfig(columns=[ColumnSchema(key="size", label="Size")])
assert (
apply_column_visibility(config, content_type="ebook", is_setting_enabled=bool) is config
)
def test_rebuilds_template_when_tracks_do_not_line_up(self):
config = ReleaseColumnConfig(
columns=[
ColumnSchema(key="a", label="A", width="60px"),
ColumnSchema(key="b", label="B", width="70px", content_types=("audiobook",)),
],
grid_template="minmax(0, 2fr) 60px",
)
result = apply_column_visibility(
config, content_type="ebook", is_setting_enabled=lambda _k: True
)
assert result.grid_template == "minmax(0, 2fr) 60px"
def test_gates_are_not_serialized(self):
config = ReleaseColumnConfig(
columns=[ColumnSchema(key="a", label="A", setting_key="X", content_types=("ebook",))]
)
column = serialize_column_config(config)["columns"][0]
assert "setting_key" not in column
assert "content_types" not in column