Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/listarr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"]
120 changes: 119 additions & 1 deletion src/listarr/api/mdblist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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,
*,
Expand All @@ -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
Expand All @@ -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 = (
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/listarr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/listarr/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,3 +44,4 @@ class SyncResult:
existing: int
not_found: int
dry_run: bool
not_found_items: tuple[NotFoundItem, ...] = ()
34 changes: 26 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading