feat(prowlarr): warn when an indexer declares a format Shelfmark can't process (#1265)

## Problem

Companion to #1264, but general rather than mp4-specific.

MyAnonamouse titles carry a structured `[LANG / FORMATS]` bracket that
`_extract_mam_formats` parses. When every token in it is something
Shelfmark doesn't know — e.g. `The Martian by Andy Weir [ENG / MP4]` —
the release is rendered with **no format chip at all**, just the generic
headphones/book icon with an "Audiobook" tooltip. To a user that looks
like an ordinary result. It downloads fine and then fails
post-processing with *"No book files found in download"*.

The backend already *had* the signal (a format token it couldn't map);
it just threw it away.

## Change

**Backend** (`shelfmark/release_sources/prowlarr/source.py`)
- `_split_mam_formats(raw_title) -> (recognized, unrecognized)` replaces
the body of `_extract_mam_formats`, which is kept as a thin wrapper
returning `recognized` so nothing else changes.
- Releases gain `extra["unrecognized_formats"]` (list, or `None` when
empty / when format detection is off).

**Frontend**
- `getUnrecognizedReleaseFormats(release)` in `utils/releaseFormats.ts`
(normalised + deduped, same shape as `getReleaseFormats`).
- `ReleaseCell` `format_content_type`: when there is **no** recognised
format but the indexer named one, render an amber `MP4 Unsupported`
badge (compact view: amber `MP4`) with tooltip *"Unsupported format
(MP4) - Shelfmark cannot process this release"*. When a recognised
format exists the existing badge is untouched, even if extra unknown
tokens were present.

Only the chip changes — the download button still works, so a user can
still grab and hand-process the files if they want to. Happy to disable
the button instead if you'd prefer.

## Tests

- `tests/prowlarr/test_source.py`: `TestSplitMamFormats` (recognised /
unrecognised / mixed / no bracket / wrapper compat) and
`TestUnrecognizedFormatOnRelease` (lands in `extra`, empty when
recognised, absent without format detection).
- `src/frontend/src/tests/releaseFormats.test.ts`: 3 cases for the new
helper.
- `ruff check` clean; `pytest tests/prowlarr -m "not integration"` 511
passed; `tsc --noEmit`, `oxlint --deny warnings`, `vitest` all clean.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jakesterpdx
2026-08-24 17:50:27 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 65e2e3be20
commit 9bcf595111
5 changed files with 187 additions and 9 deletions
+29 -8
View File
@@ -317,19 +317,25 @@ def _extract_mam_language(raw_title: str) -> str | None:
return None
def _extract_mam_formats(raw_title: str) -> list[str]:
"""Extract a list of formats from MyAnonamouse titles.
def _split_mam_formats(raw_title: str) -> tuple[list[str], list[str]]:
"""Split the format tokens of a MyAnonamouse title into (recognized, unrecognized).
Prowlarr's MAM parser appends a structured bracket segment like:
[ENG / EPUB MOBI PDF]
We only trust this structured segment (and do not attempt generic title
heuristics for other indexers).
Tokens after the "/" that Shelfmark does not know as a book or audiobook format
(e.g. ``[ENG / MP4]``) are returned separately so the UI can warn that the release
will download but cannot be processed, instead of showing a bare content-type icon
that looks like an ordinary result.
"""
if not raw_title:
return []
return [], []
format_set = set(ALL_BOOK_FORMATS)
first_unrecognized: list[str] | None = None
for bracket in re.findall(r"\[([^\]]+)\]", raw_title):
if "/" not in bracket:
continue
@@ -338,15 +344,26 @@ def _extract_mam_formats(raw_title: str) -> list[str]:
tokens = re.findall(r"[A-Za-z0-9]+", after_slash)
formats: list[str] = []
unrecognized: list[str] = []
for token in tokens:
fmt = token.lower()
if fmt in format_set and fmt not in formats:
formats.append(fmt)
if fmt in format_set:
if fmt not in formats:
formats.append(fmt)
elif fmt not in unrecognized:
unrecognized.append(fmt)
if formats:
return formats
return formats, unrecognized
if unrecognized and first_unrecognized is None:
first_unrecognized = unrecognized
return []
return [], first_unrecognized or []
def _extract_mam_formats(raw_title: str) -> list[str]:
"""Extract the recognized formats from a MyAnonamouse title (see _split_mam_formats)."""
return _split_mam_formats(raw_title)[0]
def _formats_display(formats: list[str]) -> str | None:
@@ -485,6 +502,7 @@ def _prowlarr_result_to_release(
format_detected: str | None = None
formats: list[str] = []
unrecognized_formats: list[str] = []
formats_display: str | None = None
language_detected: str | None = None
if enable_format_detection:
@@ -492,7 +510,7 @@ def _prowlarr_result_to_release(
if book_title:
title = book_title
formats = _extract_mam_formats(str(raw_title or ""))
formats, unrecognized_formats = _split_mam_formats(str(raw_title or ""))
format_detected = formats[0] if formats else None
formats_display = _formats_display(formats)
language_detected = _extract_mam_language(str(raw_title or ""))
@@ -554,6 +572,9 @@ def _prowlarr_result_to_release(
"info_hash": result.get("infoHash"),
"formats": formats or None,
"formats_display": formats_display,
# Format tokens the indexer declared but Shelfmark can't process (#1264-style
# "[ENG / MP4]"). Lets the UI warn instead of showing a bare content icon.
"unrecognized_formats": unrecognized_formats or None,
# Raw torznab attributes for rich tooltips (enriched indexers)
"torznab_attrs": result.get("torznabAttrs"),
},
@@ -8,6 +8,7 @@ import {
toStringArray,
toStringValue,
} from '../utils/objectHelpers';
import { getUnrecognizedReleaseFormats } from '../utils/releaseFormats';
import { Tooltip } from './shared/Tooltip';
interface ReleaseCellProps {
@@ -424,6 +425,38 @@ export const ReleaseCell = ({
const primaryFormat = formats?.[0] || null;
const additionalFormats = formats?.slice(1) || [];
// The indexer named a format Shelfmark can't process (e.g. MAM "[ENG / MP4]").
// Downloading it would only fail post-processing, so warn instead of showing the
// bare content-type icon that makes it look like any other result.
const unrecognizedFormats = primaryFormat ? [] : getUnrecognizedReleaseFormats(release);
if (unrecognizedFormats.length > 0) {
const unsupportedLabel = unrecognizedFormats.map((fmt) => fmt.toUpperCase()).join(', ');
const unsupportedTitle = `Unsupported format (${unsupportedLabel}) - Shelfmark cannot process this release`;
if (compact) {
return (
<span
className="font-semibold text-amber-600 dark:text-amber-400"
title={unsupportedTitle}
>
{unrecognizedFormats[0].toUpperCase()}
{unrecognizedFormats.length > 1 && ` +${unrecognizedFormats.length - 1}`}
</span>
);
}
return (
<div className="flex items-center justify-start" title={unsupportedTitle}>
<span className="inline-flex items-center gap-1">
<span className="w-13 rounded-lg bg-amber-500/20 py-0.5 text-center text-[10px] font-semibold tracking-wide whitespace-nowrap text-amber-700 sm:text-[11px] dark:text-amber-400">
{unrecognizedFormats[0].toUpperCase()}
</span>
<span className="text-[10px] font-medium whitespace-nowrap text-amber-700 sm:text-[11px] dark:text-amber-400">
Unsupported
</span>
</span>
</div>
);
}
// Use blue for book, violet for audiobook when no format specified
const noFormatStyle = isAudiobook
? { bg: 'bg-violet-500/20', text: 'text-violet-600 dark:text-violet-400' }
+24 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { Release } from '../types';
import { getReleaseFormats } from '../utils/releaseFormats';
import { getReleaseFormats, getUnrecognizedReleaseFormats } from '../utils/releaseFormats';
function buildRelease(overrides: Partial<Release>): Release {
return {
@@ -38,3 +38,26 @@ describe('releaseFormats.getReleaseFormats', () => {
expect(getReleaseFormats(release)).toEqual(['pdf']);
});
});
describe('releaseFormats.getUnrecognizedReleaseFormats', () => {
it('returns normalized, deduplicated unrecognized formats from extra', () => {
const release = buildRelease({
extra: { unrecognized_formats: ['MP4', ' mp4 ', 'WEBM'] },
});
expect(getUnrecognizedReleaseFormats(release)).toEqual(['mp4', 'webm']);
});
it('accepts a single string value', () => {
const release = buildRelease({ extra: { unrecognized_formats: 'MP4' } });
expect(getUnrecognizedReleaseFormats(release)).toEqual(['mp4']);
});
it('returns an empty list when nothing was flagged', () => {
expect(getUnrecognizedReleaseFormats(buildRelease({}))).toEqual([]);
expect(
getUnrecognizedReleaseFormats(buildRelease({ extra: { unrecognized_formats: null } })),
).toEqual([]);
});
});
+24
View File
@@ -33,3 +33,27 @@ export function getReleaseFormats(release: Release): string[] {
return formats;
}
/**
* Format tokens the indexer declared but the backend could not map to a known
* book/audiobook format (e.g. MyAnonamouse "[ENG / MP4]"). Such a release will
* download but fail post-processing, so the UI warns instead of showing a bare
* content-type icon.
*/
export function getUnrecognizedReleaseFormats(release: Release): string[] {
const raw = release.extra?.unrecognized_formats;
const values = Array.isArray(raw) ? raw : [raw];
const formats: string[] = [];
const seen = new Set<string>();
values.forEach((value) => {
const normalized = normalizeFormatValue(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
formats.push(normalized);
});
return formats;
}
+77
View File
@@ -1398,3 +1398,80 @@ class TestSearchBudgetScalesWithIndexerTimeout:
def test_budget_stays_under_the_gunicorn_worker_timeout(self):
assert _search_budget_seconds(300) == 240.0
class TestSplitMamFormats:
"""MAM's structured "[LANG / FORMATS]" bracket, split into known vs unknown tokens."""
def test_recognized_only(self):
from shelfmark.release_sources.prowlarr.source import _split_mam_formats
assert _split_mam_formats("Title by Author [ENG / EPUB MOBI]") == (["epub", "mobi"], [])
def test_unrecognized_only_is_surfaced(self):
from shelfmark.release_sources.prowlarr.source import _split_mam_formats
assert _split_mam_formats("The Martian by Andy Weir [ENG / MP4]") == ([], ["mp4"])
def test_mixed_keeps_both_sides(self):
from shelfmark.release_sources.prowlarr.source import _split_mam_formats
assert _split_mam_formats("Title [ENG / M4B MP4]") == (["m4b"], ["mp4"])
def test_no_structured_bracket(self):
from shelfmark.release_sources.prowlarr.source import _split_mam_formats
assert _split_mam_formats("Title [VIP]") == ([], [])
assert _split_mam_formats("") == ([], [])
def test_extract_mam_formats_still_returns_recognized(self):
from shelfmark.release_sources.prowlarr.source import _extract_mam_formats
assert _extract_mam_formats("Title [ENG / MP3]") == ["mp3"]
assert _extract_mam_formats("Title [ENG / MP4]") == []
class TestUnrecognizedFormatOnRelease:
def _result(self, title: str) -> dict:
return {
"title": title,
"guid": "https://www.myanonamouse.net/t/627978",
"indexer": "MyAnonamouse",
"indexerId": 1,
"protocol": "torrent",
"size": 320000000,
"seeders": 800,
"leechers": 0,
"categories": [{"id": 3030}],
}
def test_unrecognized_format_lands_in_extra(self):
from shelfmark.release_sources.prowlarr.source import _prowlarr_result_to_release
release = _prowlarr_result_to_release(
self._result("The Martian by Andy Weir [ENG / MP4]"),
"audiobook",
enable_format_detection=True,
)
assert release.format is None
assert release.extra["formats"] is None
assert release.extra["unrecognized_formats"] == ["mp4"]
def test_recognized_format_leaves_unrecognized_empty(self):
from shelfmark.release_sources.prowlarr.source import _prowlarr_result_to_release
release = _prowlarr_result_to_release(
self._result("The Martian by Andy Weir [ENG / M4B]"),
"audiobook",
enable_format_detection=True,
)
assert release.format == "m4b"
assert release.extra["unrecognized_formats"] is None
def test_not_populated_without_format_detection(self):
from shelfmark.release_sources.prowlarr.source import _prowlarr_result_to_release
release = _prowlarr_result_to_release(
self._result("The Martian by Andy Weir [ENG / MP4]"), "audiobook"
)
assert release.extra["unrecognized_formats"] is None