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:
@@ -130,6 +130,26 @@ class SerieList:
|
||||
"""
|
||||
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 [
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
return {
|
||||
init: init,
|
||||
@@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() {
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries,
|
||||
updateSeriesKey: updateSeriesKey,
|
||||
removeSeries: removeSeries
|
||||
removeSeries: removeSeries,
|
||||
reloadSeries: reloadSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user