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

@@ -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 [

View File

@@ -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."""

View File

@@ -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

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
return {
init: init,
@@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() {
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries,
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
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true;

View File

@@ -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',