fix(delete): prune in-memory SerieList cache after delete

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.
This commit is contained in:
2026-09-04 20:16:31 +02:00
parent 62b4ca5ffc
commit 35a733d36f
7 changed files with 249 additions and 3 deletions

View File

@@ -130,6 +130,26 @@ class SerieList:
""" """
return key in self.keyDict 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]: def GetMissingEpisode(self) -> List[AnimeSeries]:
"""Return all series that still contain missing episodes.""" """Return all series that still contain missing episodes."""
return [ return [

View File

@@ -1936,6 +1936,35 @@ class AnimeService:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
pass 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 # Broadcast deletion via WebSocket
try: try:
await self._broadcast_series_deleted(key, series_name) await self._broadcast_series_deleted(key, series_name)
@@ -1945,6 +1974,20 @@ class AnimeService:
key, exc, 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 --- # --- Build message ---
self._build_delete_message(result) self._build_delete_message(result)
@@ -2124,6 +2167,28 @@ class AnimeService:
key, str(exc), 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: def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance.""" """Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -689,6 +689,35 @@ class WebSocketService:
key, name, 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 # Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None _websocket_service: Optional[WebSocketService] = None

View File

@@ -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<void>}
*/
function reloadSeries() {
console.info('[SeriesManager] Reloading series from server');
return loadSeries();
}
// Public API // Public API
return { return {
init: init, init: init,
@@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() {
updateSeriesLoadingStatus: updateSeriesLoadingStatus, updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries, updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey, updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries removeSeries: removeSeries,
reloadSeries: reloadSeries
}; };
})(); })();

View File

@@ -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 // Download events
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) { socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true; isDownloading = true;

View File

@@ -105,6 +105,10 @@ AniWorld.Constants = (function() {
SERIES_UPDATED: 'series_updated', SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update', SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted', 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 scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started', SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',

View File

@@ -646,6 +646,111 @@ class TestDeleteSeriesService:
db_delete_mock.assert_not_called() db_delete_mock.assert_not_called()
assert result.success is False 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) # Orphan folder recovery (DB row gone, folder still on disk)
# ------------------------------------------------------------------ # ------------------------------------------------------------------