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:
@@ -121,15 +121,35 @@ class SerieList:
|
|||||||
def contains(self, key: str) -> bool:
|
def contains(self, key: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True when a series identified by ``key`` already exists.
|
Return True when a series identified by ``key`` already exists.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: The unique provider identifier for the series
|
key: The unique provider identifier for the series
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the series exists in the collection
|
True if the series exists in the collection
|
||||||
"""
|
"""
|
||||||
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 [
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user