Skip to content

Commit 72aebc7

Browse files
committed
Fix duplicate calendar events and notifications for anime tracked in both buckets
- EventManager gains a cross-bucket dedup that resolves an Anime item's MAL id to a verified TMDB/TVDB id via the pinned Kometa Anime-IDs mapping and hides the Anime-bucket event when the user also actively tracks that show in the TV bucket. - notifications.get_user_releases now applies the same hidden-duplicate-id filter, which also fixes the pre-existing gap where the TMDB/TVDB cross-provider dedup (#639) wasn't applied to release notifications. Fixes #968
1 parent 74bb993 commit 72aebc7

4 files changed

Lines changed: 310 additions & 4 deletions

File tree

src/events/models.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app import config
1818
from app.models import TV, Item, MediaTypes, Season, Sources, Status
1919
from app.services.item_merge import dedupe_cross_provider_items
20+
from integrations.anime_mapping import resolve_provider_series_id
2021

2122
# Statuses that represent inactive tracking
2223
# will be ignored when creating events
@@ -76,15 +77,27 @@ def get_user_events(self, user, first_day, last_day):
7677
datetime__lte=end_datetime,
7778
).select_related("item")
7879

79-
hidden_item_ids = self._cross_provider_hidden_season_item_ids(
80-
user,
81-
enabled_types,
82-
)
80+
hidden_item_ids = self.hidden_duplicate_item_ids(user, enabled_types)
8381
if hidden_item_ids:
8482
queryset = queryset.exclude(item_id__in=hidden_item_ids)
8583

8684
return self.sort_with_sentinel_last(queryset)
8785

86+
def hidden_duplicate_item_ids(self, user, enabled_types):
87+
"""Return `Item` ids to exclude because a preferred duplicate exists.
88+
89+
Combines the TMDB/TVDB cross-provider dedup (#639) with the
90+
Anime/TV cross-bucket dedup (#968), so both the calendar and
91+
release notifications hide the same duplicates.
92+
"""
93+
return self._cross_provider_hidden_season_item_ids(
94+
user,
95+
enabled_types,
96+
) | self._cross_bucket_hidden_anime_item_ids(
97+
user,
98+
enabled_types,
99+
)
100+
88101
def _active_tv_show_media_ids(self, user):
89102
"""Return media_ids of the user's actively-tracked TV shows."""
90103
return (
@@ -140,6 +153,58 @@ def _cross_provider_hidden_season_item_ids(self, user, enabled_types):
140153
kept_ids = {item.id for item in deduped}
141154
return {item.id for item in season_items if item.id not in kept_ids}
142155

156+
def _cross_bucket_hidden_anime_item_ids(self, user, enabled_types):
157+
"""Return Anime `Item` ids to hide because a tracked TV counterpart exists.
158+
159+
A user can track the same real show both in the Anime bucket (MAL)
160+
and the TV bucket (TMDB/TVDB) - each gets its own independent `Item`
161+
and `Event`s, so without this the calendar and release notifications
162+
fire twice for the same episode (#968). Uses the pinned Kometa
163+
Anime-IDs mapping (`integrations.anime_mapping`) to resolve a MAL id
164+
to its verified TMDB/TVDB series id - never a title guess. The TV
165+
bucket is always kept since it carries season/episode structure; the
166+
Anime bucket duplicate is hidden.
167+
"""
168+
if MediaTypes.ANIME.value not in enabled_types:
169+
return set()
170+
if not (
171+
MediaTypes.TV.value in enabled_types
172+
or MediaTypes.SEASON.value in enabled_types
173+
):
174+
return set()
175+
176+
active_anime_items = list(
177+
Item.objects.filter(
178+
media_type=MediaTypes.ANIME.value,
179+
source=Sources.MAL.value,
180+
anime__user=user,
181+
).exclude(anime__status__in=INACTIVE_TRACKING_STATUSES),
182+
)
183+
if not active_anime_items:
184+
return set()
185+
186+
active_tv_shows = set(
187+
TV.objects.filter(
188+
user=user,
189+
item__media_type=MediaTypes.TV.value,
190+
)
191+
.exclude(status__in=INACTIVE_TRACKING_STATUSES)
192+
.values_list("item__source", "item__media_id"),
193+
)
194+
if not active_tv_shows:
195+
return set()
196+
197+
hidden_ids = set()
198+
for anime_item in active_anime_items:
199+
tmdb_id = resolve_provider_series_id(anime_item.media_id, "tmdb")
200+
tvdb_id = resolve_provider_series_id(anime_item.media_id, "tvdb")
201+
if (tmdb_id and (Sources.TMDB.value, tmdb_id) in active_tv_shows) or (
202+
tvdb_id and (Sources.TVDB.value, tvdb_id) in active_tv_shows
203+
):
204+
hidden_ids.add(anime_item.id)
205+
206+
return hidden_ids
207+
143208
def _build_tv_query(self, user, enabled_types):
144209
"""Build query for TV shows based on TV status and season statuses."""
145210
if not (

src/events/notifications.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,16 @@ def get_user_releases(users, target_events):
230230
user_events = []
231231
enabled_types = user_enabled_types[user.id]
232232
excluded_items = user_exclusions.get(user.id, set())
233+
hidden_item_ids = Event.objects.hidden_duplicate_item_ids(
234+
user,
235+
enabled_types,
236+
)
233237

234238
for event in target_events.values():
239+
# Check if a preferred cross-provider/cross-bucket duplicate exists
240+
if event.item.id in hidden_item_ids:
241+
continue
242+
235243
# Check if user has excluded this item
236244
if event.item.media_type != Season and event.item.id in excluded_items:
237245
continue

src/events/tests/test_models.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
from unittest import mock
23

34
from django.contrib.auth import get_user_model
45
from django.test import TestCase
@@ -466,3 +467,141 @@ def test_get_user_events_stays_a_queryset_for_media_type_filtering(self):
466467
filtered = events.filter(item__media_type__in=[MediaTypes.SEASON.value])
467468

468469
self.assertEqual(filtered.count(), 1)
470+
471+
472+
class EventManagerCrossBucketAnimeDedupTests(TestCase):
473+
"""The calendar must not show the same real episode twice across buckets (#968)."""
474+
475+
def setUp(self):
476+
self.credentials = {"username": "dedup-anime-user", "password": "testpass123"}
477+
self.user = get_user_model().objects.create_user(**self.credentials)
478+
479+
def _anime_item(self, mal_id="52991"):
480+
anime_item = Item.objects.create(
481+
title="Test Anime",
482+
media_id=mal_id,
483+
media_type=MediaTypes.ANIME.value,
484+
source=Sources.MAL.value,
485+
image="",
486+
)
487+
Anime.objects.create(
488+
item=anime_item,
489+
user=self.user,
490+
status=Status.IN_PROGRESS.value,
491+
)
492+
return anime_item
493+
494+
def _tv_show(self, media_id="1396", source=Sources.TMDB.value):
495+
tv_item = Item.objects.create(
496+
title="Test TV Show",
497+
media_id=media_id,
498+
media_type=MediaTypes.TV.value,
499+
source=source,
500+
image="",
501+
)
502+
season_item = Item.objects.create(
503+
title="Test TV Show - Season 1",
504+
media_id=media_id,
505+
media_type=MediaTypes.SEASON.value,
506+
source=source,
507+
image="",
508+
season_number=1,
509+
)
510+
TV.objects.create(
511+
item=tv_item,
512+
user=self.user,
513+
status=Status.IN_PROGRESS.value,
514+
)
515+
return season_item
516+
517+
def test_hides_anime_duplicate_with_matching_tmdb_show(self):
518+
anime_item = self._anime_item()
519+
season_item = self._tv_show(media_id="1396", source=Sources.TMDB.value)
520+
when = timezone.now() + datetime.timedelta(days=1)
521+
anime_event = Event.objects.create(
522+
item=anime_item,
523+
content_number=1,
524+
datetime=when,
525+
)
526+
season_event = Event.objects.create(
527+
item=season_item,
528+
content_number=1,
529+
datetime=when,
530+
)
531+
532+
with mock.patch(
533+
"events.models.resolve_provider_series_id",
534+
side_effect=lambda mal_id, provider: (
535+
"1396" if provider == "tmdb" else None
536+
),
537+
):
538+
events = Event.objects.get_user_events(self.user, when.date(), when.date())
539+
540+
self.assertEqual(list(events), [season_event])
541+
self.assertNotIn(anime_event, events)
542+
543+
def test_hides_anime_duplicate_with_matching_tvdb_show(self):
544+
anime_item = self._anime_item()
545+
season_item = self._tv_show(media_id="81189", source=Sources.TVDB.value)
546+
when = timezone.now() + datetime.timedelta(days=1)
547+
anime_event = Event.objects.create(
548+
item=anime_item,
549+
content_number=1,
550+
datetime=when,
551+
)
552+
season_event = Event.objects.create(
553+
item=season_item,
554+
content_number=1,
555+
datetime=when,
556+
)
557+
558+
with mock.patch(
559+
"events.models.resolve_provider_series_id",
560+
side_effect=lambda mal_id, provider: (
561+
"81189" if provider == "tvdb" else None
562+
),
563+
):
564+
events = Event.objects.get_user_events(self.user, when.date(), when.date())
565+
566+
self.assertEqual(list(events), [season_event])
567+
self.assertNotIn(anime_event, events)
568+
569+
def test_keeps_anime_event_without_tv_counterpart(self):
570+
anime_item = self._anime_item()
571+
when = timezone.now() + datetime.timedelta(days=1)
572+
anime_event = Event.objects.create(
573+
item=anime_item,
574+
content_number=1,
575+
datetime=when,
576+
)
577+
578+
with mock.patch(
579+
"events.models.resolve_provider_series_id",
580+
return_value=None,
581+
):
582+
events = Event.objects.get_user_events(self.user, when.date(), when.date())
583+
584+
self.assertEqual(list(events), [anime_event])
585+
586+
def test_keeps_both_when_mapping_does_not_match_tracked_show(self):
587+
anime_item = self._anime_item()
588+
season_item = self._tv_show(media_id="1396", source=Sources.TMDB.value)
589+
when = timezone.now() + datetime.timedelta(days=1)
590+
anime_event = Event.objects.create(
591+
item=anime_item,
592+
content_number=1,
593+
datetime=when,
594+
)
595+
season_event = Event.objects.create(
596+
item=season_item,
597+
content_number=1,
598+
datetime=when,
599+
)
600+
601+
with mock.patch(
602+
"events.models.resolve_provider_series_id",
603+
return_value=None,
604+
):
605+
events = Event.objects.get_user_events(self.user, when.date(), when.date())
606+
607+
self.assertCountEqual(list(events), [anime_event, season_event])

src/events/tests/test_notification.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,3 +1321,97 @@ def test_daily_digest_with_notification_urls(self, mock_send_notifications):
13211321

13221322
# Verify the result message
13231323
self.assertEqual(result, "Daily digest sent for 5 releases")
1324+
1325+
1326+
class CrossBucketAnimeNotificationTests(TestCase):
1327+
"""Release notifications must not duplicate a show across Anime and TV (#968)."""
1328+
1329+
def setUp(self):
1330+
self.credentials = {
1331+
"username": "notify-dedup-user",
1332+
"password": "12345",
1333+
"notification_urls": "https://example.com/notify",
1334+
}
1335+
self.user = get_user_model().objects.create_user(**self.credentials)
1336+
1337+
self.anime_item = Item.objects.create(
1338+
media_id="52991",
1339+
source=Sources.MAL.value,
1340+
media_type=MediaTypes.ANIME.value,
1341+
title="Test Anime",
1342+
image="http://example.com/anime.jpg",
1343+
)
1344+
self.tv_show_item = Item.objects.create(
1345+
media_id="1396",
1346+
source=Sources.TMDB.value,
1347+
media_type=MediaTypes.TV.value,
1348+
title="Test TV Show",
1349+
image="http://example.com/tv.jpg",
1350+
)
1351+
self.season1_item = Item.objects.create(
1352+
media_id="1396",
1353+
source=Sources.TMDB.value,
1354+
media_type=MediaTypes.SEASON.value,
1355+
title="Test TV Show - Season 1",
1356+
season_number=1,
1357+
image="http://example.com/tv.jpg",
1358+
)
1359+
1360+
Anime.objects.create(
1361+
item=self.anime_item,
1362+
user=self.user,
1363+
status=Status.IN_PROGRESS.value,
1364+
)
1365+
TV.objects.create(
1366+
item=self.tv_show_item,
1367+
user=self.user,
1368+
status=Status.IN_PROGRESS.value,
1369+
)
1370+
1371+
now = timezone.now()
1372+
ten_mins_ago = now - timedelta(minutes=10)
1373+
self.anime_event = Event.objects.create(
1374+
item=self.anime_item,
1375+
content_number=1,
1376+
datetime=ten_mins_ago,
1377+
notification_sent=False,
1378+
)
1379+
self.season1_event = Event.objects.create(
1380+
item=self.season1_item,
1381+
content_number=1,
1382+
datetime=ten_mins_ago,
1383+
notification_sent=False,
1384+
)
1385+
1386+
def test_get_user_releases_hides_anime_duplicate_of_tracked_tv_show(self):
1387+
users_with_notifications = (
1388+
get_user_model()
1389+
.objects.filter(~models.Q(notification_urls=""))
1390+
.prefetch_related("notification_excluded_items")
1391+
)
1392+
target_events = {
1393+
(
1394+
self.anime_event.item.id,
1395+
self.anime_event.content_number,
1396+
): self.anime_event,
1397+
(
1398+
self.season1_event.item.id,
1399+
self.season1_event.content_number,
1400+
): self.season1_event,
1401+
}
1402+
1403+
with patch(
1404+
"events.models.resolve_provider_series_id",
1405+
side_effect=lambda mal_id, provider: (
1406+
"1396" if provider == "tmdb" else None
1407+
),
1408+
):
1409+
user_releases = get_user_releases(users_with_notifications, target_events)
1410+
1411+
user_events = user_releases[self.user.id]
1412+
self.assertTrue(
1413+
any(event.id == self.season1_event.id for event in user_events),
1414+
)
1415+
self.assertFalse(
1416+
any(event.id == self.anime_event.id for event in user_events),
1417+
)

0 commit comments

Comments
 (0)