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