diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py
index 16e6779..a2e635f 100644
--- a/shelfmark/release_sources/prowlarr/source.py
+++ b/shelfmark/release_sources/prowlarr/source.py
@@ -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"),
},
diff --git a/src/frontend/src/components/ReleaseCell.tsx b/src/frontend/src/components/ReleaseCell.tsx
index ccc3ca2..523e476 100644
--- a/src/frontend/src/components/ReleaseCell.tsx
+++ b/src/frontend/src/components/ReleaseCell.tsx
@@ -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 (
+
+ {unrecognizedFormats[0].toUpperCase()}
+ {unrecognizedFormats.length > 1 && ` +${unrecognizedFormats.length - 1}`}
+
+ );
+ }
+ return (
+
+
+
+ {unrecognizedFormats[0].toUpperCase()}
+
+
+ Unsupported
+
+
+
+ );
+ }
+
// 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' }
diff --git a/src/frontend/src/tests/releaseFormats.test.ts b/src/frontend/src/tests/releaseFormats.test.ts
index e37de4a..6d1af63 100644
--- a/src/frontend/src/tests/releaseFormats.test.ts
+++ b/src/frontend/src/tests/releaseFormats.test.ts
@@ -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 {
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([]);
+ });
+});
diff --git a/src/frontend/src/utils/releaseFormats.ts b/src/frontend/src/utils/releaseFormats.ts
index 6dcf629..864e126 100644
--- a/src/frontend/src/utils/releaseFormats.ts
+++ b/src/frontend/src/utils/releaseFormats.ts
@@ -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();
+
+ values.forEach((value) => {
+ const normalized = normalizeFormatValue(value);
+ if (!normalized || seen.has(normalized)) {
+ return;
+ }
+ seen.add(normalized);
+ formats.push(normalized);
+ });
+
+ return formats;
+}
diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py
index 556aa70..e5cd28c 100644
--- a/tests/prowlarr/test_source.py
+++ b/tests/prowlarr/test_source.py
@@ -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