From 35a733d36f2c8330c4229566c478737c053b1a2b Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 4 Sep 2026 20:16:31 +0200 Subject: [PATCH] fix(delete): prune in-memory SerieList cache after delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_series() removed the row from the database and the folder from disk, but never evicted the entry from SerieList.keyDict — the in-memory cache that list_series_with_filters() reads from via SeriesApp.list.GetList(). As a result /api/anime kept returning the deleted series on every page reload (the 'Beyblade Burst still listed after delete' bug). Changes: * Add SerieList.remove(key) so the cache has a proper eviction API. * In delete_series(), call it (or fall back to keyDict.pop) after a successful DB delete. * Broadcast a broader series_list_changed event so any client that missed the specific series_deleted event can re-sync by re-fetching /api/anime. Front-end: new SERIES_LIST_CHANGED constant, handler that triggers SeriesManager.reloadSeries(). * Two new regression tests: one asserting the in-memory cache is pruned, one asserting the broader broadcast fires. --- src/server/database/SerieList.py | 24 +++- src/server/services/anime_service.py | 65 +++++++++++ src/server/services/websocket_service.py | 29 +++++ .../web/static/js/index/series-manager.js | 14 ++- .../web/static/js/index/socket-handler.js | 11 ++ src/server/web/static/js/shared/constants.js | 4 + tests/unit/test_delete_anime_service.py | 105 ++++++++++++++++++ 7 files changed, 249 insertions(+), 3 deletions(-) diff --git a/src/server/database/SerieList.py b/src/server/database/SerieList.py index cf84579..47d094c 100644 --- a/src/server/database/SerieList.py +++ b/src/server/database/SerieList.py @@ -121,15 +121,35 @@ class SerieList: def contains(self, key: str) -> bool: """ Return True when a series identified by ``key`` already exists. - + Args: key: The unique provider identifier for the series - + Returns: True if the series exists in the collection """ return key in self.keyDict + def remove(self, key: str) -> bool: + """Remove a series from the in-memory cache. + + Used by the delete flow to keep the in-memory cache in sync with + the database after a row is deleted. Returns True if an entry + was present and removed, False if the key was not in the cache + (treating "already gone" as a no-op rather than an error). + + Args: + key: The unique provider identifier for the series + + Returns: + True if a series was removed, False if it was not cached. + """ + if key in self.keyDict: + del self.keyDict[key] + logger.debug("Removed series from in-memory cache: key=%s", key) + return True + return False + def GetMissingEpisode(self) -> List[AnimeSeries]: """Return all series that still contain missing episodes.""" return [ diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index e89aa5e..3a1927b 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -1936,6 +1936,35 @@ class AnimeService: except Exception: # pylint: disable=broad-except pass + # Evict from in-memory SerieList.keyDict so the next + # GET /api/anime (which reads via SeriesApp.list.GetList()) + # does not return the just-deleted series. + try: + list_obj = getattr(self._app, "list", None) + if list_obj is not None: + # Prefer the explicit remove() when available. + if hasattr(list_obj, "remove"): + removed = list_obj.remove(key) + else: + # Fallback: mutate the underlying keyDict dict + # directly (mirrors how add_to_db() writes). + key_dict = getattr(list_obj, "keyDict", None) + removed = ( + key_dict is not None + and key_dict.pop(key, None) is not None + ) + if removed: + logger.info( + "Evicted series from in-memory cache: key=%s", + key, + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Failed to evict series from in-memory cache: " + "key=%s error=%s", + key, exc, + ) + # Broadcast deletion via WebSocket try: await self._broadcast_series_deleted(key, series_name) @@ -1945,6 +1974,20 @@ class AnimeService: key, exc, ) + # Broadcast the broader series_list_changed event so any + # connected client that missed the series_deleted message + # (or whose local state drifted) can re-sync by re-fetching + # /api/anime. This is the durable fix for the + # "deleted but still listed" bug. + try: + await self._broadcast_series_list_changed(reason="deleted") + except Exception as exc: + logger.warning( + "Failed to broadcast series_list_changed after delete: " + "key=%s error=%s", + key, exc, + ) + # --- Build message --- self._build_delete_message(result) @@ -2124,6 +2167,28 @@ class AnimeService: key, str(exc), ) + async def _broadcast_series_list_changed(self, reason: str = "updated") -> None: + """Broadcast series_list_changed event via WebSocket. + + Fires whenever the membership of the series list changes + (delete, bulk import, rescan completion, …). The front-end + can use this as a hint to re-fetch /api/anime so its local + state cannot drift from the server's in-memory cache. + """ + try: + await self._websocket_service.broadcast_series_list_changed( + reason=reason, + ) + logger.info( + "series_list_changed broadcast sent: reason=%s", + reason, + ) + except Exception as exc: + logger.warning( + "Failed to broadcast series_list_changed: reason=%s error=%s", + reason, str(exc), + ) + def get_anime_service(series_app: SeriesApp) -> AnimeService: """Factory used for creating AnimeService with a SeriesApp instance.""" diff --git a/src/server/services/websocket_service.py b/src/server/services/websocket_service.py index 9bd26ec..7cfb9af 100644 --- a/src/server/services/websocket_service.py +++ b/src/server/services/websocket_service.py @@ -689,6 +689,35 @@ class WebSocketService: key, name, ) + async def broadcast_series_list_changed( + self, + reason: str = "updated", + ) -> None: + """Broadcast a series_list_changed event to all connected clients. + + Fires whenever the membership of the series list changes + (delete, bulk import, rescan completion, …). Clients use this + as a hint to re-fetch /api/anime so their local state cannot + drift from the server's in-memory cache. + + Args: + reason: Short string describing why the list changed + (e.g. "deleted", "imported", "rescanned"). Forwarded + to the client for logging/debugging only. + """ + message = { + "type": "series_list_changed", + "timestamp": datetime.now(timezone.utc).isoformat(), + "data": { + "reason": reason, + }, + } + await self._manager.broadcast(message) + logger.info( + "Broadcast series_list_changed reason=%s", + reason, + ) + # Singleton instance for application-wide access _websocket_service: Optional[WebSocketService] = None diff --git a/src/server/web/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js index fadfeb6..ea0320c 100644 --- a/src/server/web/static/js/index/series-manager.js +++ b/src/server/web/static/js/index/series-manager.js @@ -584,6 +584,17 @@ AniWorld.SeriesManager = (function() { } } + /** + * Re-fetch the series list from the server. Used as a durable + * backstop when receiving the broader series_list_changed WS event + * so local state cannot drift from the server's in-memory cache. + * @returns {Promise} + */ + function reloadSeries() { + console.info('[SeriesManager] Reloading series from server'); + return loadSeries(); + } + // Public API return { init: init, @@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() { updateSeriesLoadingStatus: updateSeriesLoadingStatus, updateSingleSeries: updateSingleSeries, updateSeriesKey: updateSeriesKey, - removeSeries: removeSeries + removeSeries: removeSeries, + reloadSeries: reloadSeries }; })(); diff --git a/src/server/web/static/js/index/socket-handler.js b/src/server/web/static/js/index/socket-handler.js index 2cd03dd..698947c 100644 --- a/src/server/web/static/js/index/socket-handler.js +++ b/src/server/web/static/js/index/socket-handler.js @@ -169,6 +169,17 @@ AniWorld.IndexSocketHandler = (function() { } }); + // Series list membership changed (delete, bulk import, rescan, …). + // Re-fetch /api/anime so local state cannot drift from the server's + // in-memory cache. Acts as a durable backstop when the more specific + // series_deleted event does not reach the client. + socket.on(WS_EVENTS.SERIES_LIST_CHANGED, function(data) { + console.info('[SocketHandler] Series list changed:', data); + if (AniWorld.SeriesManager && AniWorld.SeriesManager.reloadSeries) { + AniWorld.SeriesManager.reloadSeries(); + } + }); + // Download events socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) { isDownloading = true; diff --git a/src/server/web/static/js/shared/constants.js b/src/server/web/static/js/shared/constants.js index aeb3da6..8903332 100644 --- a/src/server/web/static/js/shared/constants.js +++ b/src/server/web/static/js/shared/constants.js @@ -105,6 +105,10 @@ AniWorld.Constants = (function() { SERIES_UPDATED: 'series_updated', SERIES_LOADING_UPDATE: 'series_loading_update', SERIES_DELETED: 'series_deleted', + // Fires when the membership of the series list changes (delete, + // bulk import, rescan completion, …). Clients should re-fetch + // /api/anime to resync with the server's in-memory cache. + SERIES_LIST_CHANGED: 'series_list_changed', // Scheduled scan events SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started', diff --git a/tests/unit/test_delete_anime_service.py b/tests/unit/test_delete_anime_service.py index e5fbfea..f286180 100644 --- a/tests/unit/test_delete_anime_service.py +++ b/tests/unit/test_delete_anime_service.py @@ -646,6 +646,111 @@ class TestDeleteSeriesService: db_delete_mock.assert_not_called() assert result.success is False + # ------------------------------------------------------------------ + # In-memory keyDict cache eviction + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_delete_series_evicts_in_memory_cache( + self, anime_service, mock_series_app + ): + """After a successful DB delete, the in-memory SerieList.keyDict + entry for that series must be removed. + + The /api/anime list endpoint reads from SeriesApp.list.keyDict + via list_series_with_filters(). If the cache is not pruned, the + deleted series keeps appearing in the listing on every page + reload — exactly the Beyblade Burst bug. + """ + from src.server.database.SerieList import SerieList + + mock_session = AsyncMock() + mock_ctx = _make_db_ctx(mock_session) + + mock_series = MagicMock() + mock_series.key = "beyblade-burst-rise" + mock_series.name = "Beyblade Burst Rise" + mock_series.folder = "Beyblade Burst Rise (2016)" + mock_series.id = 487 + + # Use a real SerieList instance — the same type the production + # code mutates — so the eviction actually exercises the real + # remove() method (a MagicMock would just return more mocks). + real_list = SerieList(str(mock_series_app.directory_to_search)) + real_list.keyDict["beyblade-burst-rise"] = mock_series + mock_series_app.list = real_list + + anime_service._websocket_service = MagicMock() + anime_service._websocket_service.broadcast_series_deleted = AsyncMock() + anime_service._websocket_service.broadcast_series_list_changed = AsyncMock() + + with patch( + "src.server.database.connection.get_db_session", + return_value=mock_ctx, + ), patch( + "src.server.database.service.AnimeSeriesService.get_by_key", + new_callable=AsyncMock, + return_value=mock_series, + ), patch( + "src.server.database.service.AnimeSeriesService.delete", + new_callable=AsyncMock, + return_value=True, + ): + result = await anime_service.delete_series( + key="beyblade-burst-rise", + delete_database=True, + delete_folder=False, + ) + + assert result.success is True + assert "beyblade-burst-rise" not in real_list.keyDict, ( + "In-memory cache still contains the deleted series — " + "/api/anime will keep returning it after a page reload" + ) + + @pytest.mark.asyncio + async def test_delete_series_broadcasts_series_list_changed( + self, anime_service, mock_series_app + ): + """A stronger ``series_list_changed`` broadcast fires after delete + so any connected client can re-sync without relying on the more + specific ``series_deleted`` event reaching them. + """ + mock_session = AsyncMock() + mock_ctx = _make_db_ctx(mock_session) + + mock_series = MagicMock() + mock_series.key = "test-key" + mock_series.name = "Test Series" + mock_series.folder = "Test Series (2023)" + mock_series.id = 1 + + mock_series_app.list.keyDict = {"test-key": mock_series} + + anime_service._websocket_service = MagicMock() + anime_service._websocket_service.broadcast_series_deleted = AsyncMock() + anime_service._websocket_service.broadcast_series_list_changed = AsyncMock() + + with patch( + "src.server.database.connection.get_db_session", + return_value=mock_ctx, + ), patch( + "src.server.database.service.AnimeSeriesService.get_by_key", + new_callable=AsyncMock, + return_value=mock_series, + ), patch( + "src.server.database.service.AnimeSeriesService.delete", + new_callable=AsyncMock, + return_value=True, + ): + await anime_service.delete_series( + key="test-key", + delete_database=True, + delete_folder=False, + ) + + anime_service._websocket_service.broadcast_series_list_changed.assert_called_once() + # ------------------------------------------------------------------ # Orphan folder recovery (DB row gone, folder still on disk) # ------------------------------------------------------------------