mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
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:
co-authored by
Claude Opus 5.5
CaliBrain
parent
bb430dfdbc
commit
a05e002305
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user