diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0a4c0..24e4bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.1.2 - 2026-08-16 + +### Added + +- List every concrete item MDBList could not match directly below the sync + status, including title, TMDB/TVDB ID, and IMDb ID when available. +- Identify exact items after a partial `not_found` response by performing one + conditional, paginated list read. + +### Optimized + +- Do not perform an extra verification request when MDBList accepts every + item, rejects none, or rejects all attempted items. When all attempted items + are rejected, Listarr already knows their identities from the request. +- Never use one lookup request per title. Partial failures require at most one + additional list traversal (one request per 1000 list items). + ## 0.1.1 - 2026-08-16 ### Fixed diff --git a/README.md b/README.md index 5c09250..f78d042 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The original and modified work are distributed under the MIT License. See - Incremental add/remove synchronization - Append (`--cat`), wipe, filtering, dry-run, and cursor pagination - Actual added, removed, existing, and not-found result reporting +- Detailed title and provider-ID output for items MDBList cannot match - Rate-limit handling using MDBList's `Retry-After` response header - No Trakt credentials or Trakt API calls @@ -96,15 +97,34 @@ After a real synchronization, Listarr reports both planned and accepted changes. For example: ```text -[Sonarr] SYNCED -> MDBList 201379 - Selected: 950 Added: 933/950 Removed: 0/0 Existing: 0 Not found: 17 +[Sonarr] SYNCED -> MDBList 12345 + Selected: 100 Added: 97/100 Removed: 0/0 Existing: 0 Not found: 3 Warning: MDBList could not match some provider IDs; those entries were not added or removed. + Not found items: + - Example rejected show [TVDB: 123456, IMDb: tt1234567] + - Example without IMDb [TVDB: 234567] ``` `Not found` means MDBList could not match the supplied TMDB, TVDB, or IMDb ID. These entries remain absent from the destination list and are not counted as successfully added. +### API request behavior for not-found details + +MDBList may return only a count for `not_found`, without the corresponding +titles or IDs. Listarr keeps request usage low while still producing exact +details: + +- no `not_found`: no additional verification request; +- every attempted item is `not_found`: no additional request, because Listarr + already knows all rejected request items; +- partial `not_found`: one additional paginated list traversal identifies the + exact rejected items; +- no per-title lookup requests are used. + +MDBList pages contain up to 1000 items, so a destination with at most 1000 +items needs at most one additional `GET` for a partial failure. + ## Synchronization safety Normal synchronization removes stale entries only from the media type being diff --git a/pyproject.toml b/pyproject.toml index c9e44ad..83d78d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "listarr" -version = "0.1.1" +version = "0.1.2" description = "Synchronize Radarr and Sonarr libraries to private MDBList lists" readme = "README.md" requires-python = ">=3.10" diff --git a/src/listarr/__init__.py b/src/listarr/__init__.py index 7ceb511..5c6b338 100644 --- a/src/listarr/__init__.py +++ b/src/listarr/__init__.py @@ -5,6 +5,6 @@ try: __version__ = version("listarr") except PackageNotFoundError: # pragma: no cover - source tree without install - __version__ = "0.1.1" + __version__ = "0.1.2" __all__ = ["__version__"] diff --git a/src/listarr/api/mdblist.py b/src/listarr/api/mdblist.py index 9505190..14a97a4 100644 --- a/src/listarr/api/mdblist.py +++ b/src/listarr/api/mdblist.py @@ -9,13 +9,14 @@ import requests from listarr.errors import APIError, RateLimitError -from listarr.models import MediaItem, SyncResult +from listarr.models import MediaItem, NotFoundItem, SyncResult @dataclass(frozen=True) class ExistingItem: provider_id: int | None imdb_id: str | None + title: str | None = None class MDBListClient: @@ -188,6 +189,7 @@ def get_items(self, list_id: int, source: str) -> list[ExistingItem]: int(provider_id) if provider_id is not None else None ), imdb_id=imdb_id, + title=item.get("title") or None, ) ) @@ -236,6 +238,66 @@ def _response_count(response: dict[str, Any], field: str, bucket: str) -> int: return len(value) return 0 + @staticmethod + def _present( + provider_id: int | None, + imdb_id: str | None, + provider_ids: set[int], + imdb_ids: set[str], + ) -> bool: + return (provider_id is not None and provider_id in provider_ids) or ( + imdb_id is not None and imdb_id in imdb_ids + ) + + def _identify_not_found( + self, + *, + list_id: int, + source: str, + provider: str, + additions: Sequence[MediaItem], + removals: Sequence[ExistingItem], + ) -> tuple[NotFoundItem, ...]: + updated = self.get_items(list_id, source) + provider_ids = { + item.provider_id for item in updated if item.provider_id is not None + } + imdb_ids = {item.imdb_id for item in updated if item.imdb_id} + missing: list[NotFoundItem] = [] + + for item in additions: + if not self._present( + item.provider_id, item.imdb_id, provider_ids, imdb_ids + ): + missing.append( + NotFoundItem( + title=item.title, + provider=provider, + provider_id=item.provider_id, + imdb_id=item.imdb_id, + ) + ) + + for item in removals: + if self._present( + item.provider_id, item.imdb_id, provider_ids, imdb_ids + ): + missing.append( + NotFoundItem( + title=item.title or "Existing list item", + provider=provider, + provider_id=item.provider_id, + imdb_id=item.imdb_id, + ) + ) + + # A provider and IMDb ID may both identify the same item. Preserve API + # order while avoiding duplicate status lines. + unique: dict[tuple[int | None, str | None], NotFoundItem] = {} + for item in missing: + unique.setdefault((item.provider_id, item.imdb_id), item) + return tuple(unique.values()) + def sync( self, *, @@ -262,6 +324,7 @@ def sync( current_imdb = {item.imdb_id for item in current if item.imdb_id} additions = [] + addition_items: list[MediaItem] = [] for item in items: already_present = item.provider_id in current_provider or ( item.imdb_id is not None and item.imdb_id in current_imdb @@ -271,8 +334,10 @@ def sync( if item.imdb_id: payload["imdb"] = item.imdb_id additions.append(payload) + addition_items.append(item) removals = [] + removal_items: list[ExistingItem] = [] if not concatenate: for item in current: still_wanted = ( @@ -284,6 +349,7 @@ def sync( removals.append({provider: item.provider_id}) elif item.imdb_id: removals.append({"imdb": item.imdb_id}) + removal_items.append(item) remove_result = { "added": 0, @@ -292,10 +358,61 @@ def sync( "not_found": 0, } add_result = remove_result.copy() + not_found_items: tuple[NotFoundItem, ...] = () if not dry_run: assert list_id is not None remove_result = self._modify(list_id, "remove", bucket, removals) add_result = self._modify(list_id, "add", bucket, additions) + if add_result["not_found"] or remove_result["not_found"]: + all_additions_failed = ( + add_result["not_found"] == len(addition_items) + and add_result["added"] == 0 + and add_result["existing"] == 0 + ) + all_removals_failed = ( + remove_result["not_found"] == len(removal_items) + and remove_result["removed"] == 0 + ) + add_outcome_known = ( + add_result["not_found"] == 0 or all_additions_failed + ) + remove_outcome_known = ( + remove_result["not_found"] == 0 or all_removals_failed + ) + + if add_outcome_known and remove_outcome_known: + known_missing = [ + NotFoundItem( + title=item.title, + provider=provider, + provider_id=item.provider_id, + imdb_id=item.imdb_id, + ) + for item in addition_items + if all_additions_failed + ] + known_missing.extend( + NotFoundItem( + title=item.title or "Existing list item", + provider=provider, + provider_id=item.provider_id, + imdb_id=item.imdb_id, + ) + for item in removal_items + if all_removals_failed + ) + not_found_items = tuple(known_missing) + else: + # MDBList returned only aggregate counts for a partial + # failure. One conditional list read is the lowest-call + # way to identify the exact items reliably. + not_found_items = self._identify_not_found( + list_id=list_id, + source=source, + provider=provider, + additions=addition_items, + removals=removal_items, + ) return SyncResult( source=source, @@ -310,6 +427,7 @@ def sync( add_result["not_found"] + remove_result["not_found"] ), dry_run=dry_run, + not_found_items=not_found_items, ) def close(self) -> None: diff --git a/src/listarr/cli.py b/src/listarr/cli.py index 1556e16..1882add 100644 --- a/src/listarr/cli.py +++ b/src/listarr/cli.py @@ -160,6 +160,18 @@ def _display(result: SyncResult, list_name: str) -> None: " Warning: MDBList could not match some provider IDs; " "those entries were not added or removed." ) + if result.not_found_items: + print(" Not found items:") + for item in result.not_found_items: + identifiers = [] + if item.provider_id is not None: + identifiers.append( + f"{item.provider.upper()}: {item.provider_id}" + ) + if item.imdb_id: + identifiers.append(f"IMDb: {item.imdb_id}") + suffix = f" [{', '.join(identifiers)}]" if identifiers else "" + print(f" - {item.title}{suffix}") def run(args: argparse.Namespace) -> int: diff --git a/src/listarr/models.py b/src/listarr/models.py index a88039d..cc60e02 100644 --- a/src/listarr/models.py +++ b/src/listarr/models.py @@ -24,6 +24,14 @@ class MediaItem: genres: tuple[str, ...] +@dataclass(frozen=True) +class NotFoundItem: + title: str + provider: str + provider_id: int | None + imdb_id: str | None + + @dataclass(frozen=True) class SyncResult: source: str @@ -36,3 +44,4 @@ class SyncResult: existing: int not_found: int dry_run: bool + not_found_items: tuple[NotFoundItem, ...] = () diff --git a/tests/test_cli.py b/tests/test_cli.py index 40cee89..ba9b9f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ from listarr.cli import _display, build_parser, main -from listarr.models import SyncResult +from listarr.models import NotFoundItem, SyncResult def test_cli_keeps_familiar_source_flags(): @@ -28,23 +28,41 @@ def test_cli_displays_actual_not_found_result(capsys): _display( SyncResult( source="Sonarr", - list_id=201379, - selected=950, - planned_add=950, + list_id=12345, + selected=100, + planned_add=100, planned_remove=0, - added=933, + added=98, removed=0, existing=0, - not_found=17, + not_found=2, dry_run=False, + not_found_items=( + NotFoundItem( + title="Rejected show", + provider="tvdb", + provider_id=123, + imdb_id="tt123", + ), + NotFoundItem( + title="Rejected without IMDb", + provider="tvdb", + provider_id=456, + imdb_id=None, + ), + ), ), "sonarr", ) output = capsys.readouterr().out - assert "Added: 933/950" in output - assert "Not found: 17" in output + assert "Added: 98/100" in output + assert "Not found: 2" in output assert "Warning:" in output + assert "Not found items:" in output + assert "Rejected show [TVDB: 123, IMDb: tt123]" in output + assert "Rejected without IMDb [TVDB: 456]" in output + assert output.index("Not found: 2") < output.index("Rejected show") def test_cli_displays_planned_dry_run_counts(capsys): diff --git a/tests/test_mdblist.py b/tests/test_mdblist.py index f68486e..2190c06 100644 --- a/tests/test_mdblist.py +++ b/tests/test_mdblist.py @@ -180,19 +180,68 @@ def test_sync_reports_actual_added_existing_and_not_found_counts(): "not_found": {"shows": 1}, } ), + FakeResponse( + { + "shows": [ + { + "title": "Accepted", + "tvdb_id": 100, + "imdb_id": "tt100", + } + ], + "pagination": {}, + } + ), ) client = MDBListClient("secret", session=session) result = client.sync( source="Sonarr", list_id=5, - items=[media(100, "tt100"), media(200, "tt200")], + items=[ + media(100, "tt100", "Accepted"), + media(200, "tt200", "Rejected"), + ], ) assert result.planned_add == 2 assert result.added == 1 assert result.existing == 0 assert result.not_found == 1 + assert [item.title for item in result.not_found_items] == ["Rejected"] + assert result.not_found_items[0].provider == "tvdb" + assert len(session.calls) == 3 + + +def test_all_additions_not_found_are_known_without_extra_list_request(): + session = FakeSession() + session.queue( + FakeResponse({"shows": [], "pagination": {}}), + FakeResponse( + { + "added": {"shows": 0}, + "existing": {"shows": 0}, + "not_found": {"shows": 2}, + } + ), + ) + client = MDBListClient("secret", session=session) + + result = client.sync( + source="Sonarr", + list_id=5, + items=[ + media(100, "tt100", "First rejected"), + media(200, None, "Second rejected"), + ], + ) + + assert result.not_found == 2 + assert [item.title for item in result.not_found_items] == [ + "First rejected", + "Second rejected", + ] + assert len(session.calls) == 2 def test_modification_counts_are_aggregated_across_batches():