mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:50:29 +01:00
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:
co-authored by
Claude Fable 5
parent
65e2e3be20
commit
9bcf595111
@@ -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' }
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user