added delete option

This commit is contained in:
2026-08-16 19:53:38 +02:00
parent 46e8b2c9eb
commit a7ed2c999c
22 changed files with 3265 additions and 3 deletions

View File

@@ -22,6 +22,8 @@ from src.server.models.anime import (
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
DeleteSeriesRequest,
DeleteSeriesResult,
TMDBSearchResult,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
@@ -1755,3 +1757,83 @@ async def regenerate_anime_nfo(
nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags,
)
@router.delete(
"/{anime_key}",
response_model=DeleteSeriesResult,
)
async def delete_anime(
anime_key: str,
request: DeleteSeriesRequest,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> DeleteSeriesResult:
"""Delete an anime series from database, filesystem, or both.
Requires typing exactly 'delete' in the confirm_text field to prevent
accidental deletions. Users can choose to remove the series from the
database only, delete the folder only, or both.
Args:
anime_key: Series key from URL path (primary identifier)
request: DeleteSeriesRequest with delete options and confirmation
_auth: Ensures the caller is authenticated
anime_service: AnimeService dependency
Returns:
DeleteSeriesResult with outcome details
Raises:
HTTPException(400): confirm_text != "delete" or no options selected
HTTPException(404): Series not found
HTTPException(500): Unexpected error
"""
# Validate confirm_text
if request.confirm_text != "delete":
logger.warning(
"Delete anime rejected - invalid confirm_text: key=%s",
anime_key,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Confirmation text must be exactly 'delete'. "
f"Got '{request.confirm_text}'."
),
)
# Validate at least one option is selected
if not request.delete_database and not request.delete_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of delete_database or delete_folder must be True.",
)
try:
result = await anime_service.delete_series(
key=anime_key,
delete_database=request.delete_database,
delete_folder=request.delete_folder,
)
if not result.success and not result.deleted_from_database:
# This means series was not found (key="" was passed as name)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result.message,
)
return result
except HTTPException:
raise
except Exception as exc:
logger.error(
"Delete anime failed: key=%s error=%s",
anime_key, str(exc),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Delete failed: {exc}",
) from exc

View File

@@ -210,6 +210,25 @@ class AnimeSeriesService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_folder_path(
db: AsyncSession,
series_key: str,
) -> Optional[str]:
"""Get the filesystem folder path for a series by its key.
Args:
db: Database session
series_key: Provider key (e.g. "attack-on-titan")
Returns:
Folder path string, or None if series not found
"""
result = await db.execute(
select(AnimeSeries.folder).where(AnimeSeries.key == series_key)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
db: AsyncSession,

View File

@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
default_factory=list,
description="Tags that were missing before regeneration",
)
class DeleteSeriesRequest(BaseModel):
"""Request payload for DELETE /api/anime/{key}.
Requires typing exactly 'delete' in confirm_text to prevent accidental deletions.
"""
delete_database: bool = Field(
default=True,
description="Whether to remove the series from the database (default: True)"
)
delete_folder: bool = Field(
default=False,
description="Whether to delete the series folder from filesystem (default: False)"
)
confirm_text: str = Field(
...,
description="Must be exactly 'delete' to confirm the operation"
)
class DeleteSeriesResult(BaseModel):
"""Result of a delete operation on a series.
Tracks what was successfully deleted and any errors encountered.
"""
success: bool = Field(..., description="Whether the operation succeeded")
key: str = Field(..., description="Series key that was deleted")
name: str = Field(..., description="Series name at time of deletion")
deleted_from_database: bool = Field(
default=False,
description="Whether the series was removed from the database"
)
deleted_folder: bool = Field(
default=False,
description="Whether the folder was deleted from filesystem"
)
folder_path: Optional[str] = Field(
None,
description="Path to the folder that was (or would be) deleted"
)
database_error: Optional[str] = Field(
None,
description="Error message if database deletion failed"
)
folder_error: Optional[str] = Field(
None,
description="Error message if folder deletion failed"
)
message: str = Field(..., description="Human-readable outcome message")

View File

@@ -1634,6 +1634,200 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> "DeleteSeriesResult":
"""Delete an anime series from database, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
from src.server.models.anime import DeleteSeriesResult
from src.server.utils.filesystem import is_safe_path
import os as _os
import shutil
logger.info(
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
key, delete_database, delete_folder,
)
# Guard: at least one deletion mode must be selected
if not delete_database and not delete_folder:
logger.warning(
"Delete series rejected - no options selected: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message="At least one of delete_database or delete_folder must be True.",
)
# Single DB session for fetch + optional delete
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Delete series failed - not found: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message=f"Series '{key}' not found.",
)
series_id = series.id
series_name = series.name
folder_path = series.folder
result = DeleteSeriesResult(
success=True,
key=key,
name=series_name,
folder_path=folder_path,
message="",
)
# --- Database deletion ---
if delete_database:
try:
async with get_db_session() as db:
deleted = await AnimeSeriesService.delete(db, series_id)
if deleted:
logger.info(
"Deleted series from database: key=%s name=%s id=%d",
key, series_name, series_id,
)
result.deleted_from_database = True
else:
# Already gone is treated as success
result.deleted_from_database = True
except Exception as exc:
logger.error(
"Failed to delete series from database: key=%s error=%s",
key, str(exc),
)
result.database_error = str(exc)
result.success = False
# Invalidate list cache
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
# Broadcast deletion via WebSocket
try:
await self._broadcast_series_deleted(key, series_name)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted for %s: %s",
key, exc,
)
# --- Filesystem deletion ---
if delete_folder and folder_path:
# Resolve absolute path and validate it is within base directory
abs_folder = _os.path.abspath(folder_path)
base_dir = _os.path.abspath(self._directory)
if not is_safe_path(base_dir, abs_folder):
logger.warning(
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
key, abs_folder, base_dir,
)
result.folder_error = (
f"Path '{abs_folder}' is outside the anime directory "
f"'{base_dir}' and will not be deleted."
)
result.success = False
elif not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
else:
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
# --- Build message ---
parts = []
if result.deleted_from_database and not result.database_error:
parts.append("removed from database")
if result.deleted_folder and not result.folder_error:
parts.append("folder deleted from filesystem")
if result.database_error:
parts.append(f"database error: {result.database_error}")
if result.folder_error:
parts.append(f"folder error: {result.folder_error}")
if parts:
result.message = "; ".join(parts)
else:
result.message = "No action taken."
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
try:
await self._websocket_service.broadcast_series_deleted(
key=key,
name=name,
)
logger.info(
"series_deleted broadcast sent: key=%s name=%s",
key, name,
)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted: key=%s error=%s",
key, str(exc),
)
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -651,9 +651,9 @@ class WebSocketService:
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service.
Broadcasts shutdown notification and closes all connections.
Args:
timeout: Maximum time (seconds) to wait for shutdown
"""
@@ -661,6 +661,34 @@ class WebSocketService:
await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete")
async def broadcast_series_deleted(
self,
key: str,
name: str,
) -> None:
"""Broadcast a series_deleted event to all connected clients.
Notifies clients that a series has been deleted so they can
remove it from their UI in real-time.
Args:
key: Series key that was deleted (primary identifier)
name: Series name for display purposes
"""
message = {
"type": "series_deleted",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"key": key,
"name": name,
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast series_deleted key=%s name=%s",
key, name,
)
# Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None

View File

@@ -311,6 +311,24 @@
color: var(--color-text-secondary);
}
.context-menu-item.danger {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger i {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger:hover {
background-color: rgba(231, 76, 60, 0.1);
}
.context-menu-divider {
height: 1px;
background: var(--color-border);
margin: var(--spacing-xs) 0;
}
/* ============================================================================
NFO Diagnostics
============================================================================ */
@@ -415,4 +433,125 @@
flex-shrink: 0;
}
/* ============================================================================
Delete Anime Modal
============================================================================ */
#delete-modal .modal-content {
max-width: 480px;
}
.delete-modal-section {
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info {
background: var(--color-background-subtle);
border-radius: var(--border-radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info p {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-body);
}
.delete-modal-series-info p:last-child {
margin-bottom: 0;
}
.delete-modal-series-name {
font-weight: 600;
color: var(--color-text-primary);
}
.delete-modal-series-key {
font-family: 'Consolas', 'Monaco', monospace;
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
.delete-modal-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.delete-modal-options label {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
cursor: pointer;
font-size: var(--font-size-body);
}
.delete-modal-options input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--color-accent);
}
.delete-modal-warning {
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
margin-top: var(--spacing-xs);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.delete-modal-confirm-label {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
.delete-modal-confirm-label strong {
color: var(--color-text-primary);
}
#delete-confirm-input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
font-size: var(--font-size-body);
background: var(--color-background);
color: var(--color-text-primary);
transition: border-color 0.15s ease;
}
#delete-confirm-input:focus {
outline: none;
border-color: var(--color-accent);
}
#delete-confirm-input.matched {
border-color: var(--color-success);
background: rgba(46, 204, 113, 0.05);
}
#delete-error {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(231, 76, 60, 0.1);
border: 1px solid var(--color-error, #e74c3c);
border-radius: var(--border-radius);
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
}
.delete-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
#delete-confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
AniWorld.DeleteModal.init();
// Bind global events
bindGlobalEvents();

View File

@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete-anime">
<i class="fa-solid fa-trash"></i>
<span>Delete Anime</span>
</div>
`;
document.body.appendChild(menuElement);
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
// Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
});
// Delete Anime - opens the confirmation modal
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
const key = currentSeriesKey;
hide();
if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
AniWorld.DeleteModal.show(key);
} else {
console.error('[ContextMenu] DeleteModal not found on AniWorld');
}
});
}
/**

View File

@@ -0,0 +1,317 @@
/**
* AniWorld - Delete Anime Modal Module
*
* Handles the delete confirmation modal for anime series.
* Users must type "delete" to enable the confirm button.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.DeleteModal = (function() {
'use strict';
const API = '/api/anime';
let currentKey = null;
let currentSeriesName = null;
let modalElement = null;
let confirmBtn = null;
let confirmInput = null;
let deleteDbCheckbox = null;
let deleteFolderCheckbox = null;
let errorElement = null;
let isSubmitting = false;
/**
* Initialize the modal — inject HTML into body if not already present.
*/
function init() {
injectModalHTML();
cacheElements();
bindEvents();
console.info('[DeleteModal] initialized');
}
/**
* Build the modal HTML once and append to document.body.
*/
function injectModalHTML() {
if (document.getElementById('delete-modal')) return;
var div = document.createElement('div');
div.id = 'delete-modal';
div.className = 'modal hidden';
div.innerHTML =
'<div class="modal-overlay"></div>' +
'<div class="modal-content">' +
'<div class="modal-header">' +
'<h3>Delete Anime</h3>' +
'</div>' +
'<div class="modal-body">' +
'<div class="delete-modal-series-info">' +
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
'</div>' +
'<div class="delete-modal-options">' +
'<label>' +
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
'Remove from database (recommended)' +
'</label>' +
'<label>' +
'<input type="checkbox" id="delete-folder-checkbox"> ' +
'Delete folder from filesystem' +
'</label>' +
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
'<i class="fas fa-exclamation-triangle"></i> ' +
'This will permanently delete the folder and ALL files inside it!' +
'</p>' +
'</div>' +
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
'Type <strong>delete</strong> to confirm:' +
'</label>' +
'<input type="text" id="delete-confirm-input" ' +
'placeholder="delete" autocomplete="off" spellcheck="false">' +
'<div id="delete-error" class="hidden"></div>' +
'</div>' +
'<div class="modal-footer delete-modal-actions">' +
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
}
/**
* Cache DOM element references.
*/
function cacheElements() {
modalElement = document.getElementById('delete-modal');
confirmBtn = document.getElementById('delete-confirm-btn');
confirmInput = document.getElementById('delete-confirm-input');
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
errorElement = document.getElementById('delete-error');
}
/**
* Bind event listeners on the modal.
*/
function bindEvents() {
// Cancel button
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
// Close on backdrop click
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
// Escape key to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
hide();
}
});
// Folder checkbox toggle — show/hide warning
deleteFolderCheckbox.addEventListener('change', function() {
var warning = document.getElementById('delete-folder-warning');
if (warning) {
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
}
});
// Confirm input — validate and update button state
confirmInput.addEventListener('input', function() {
var value = confirmInput.value;
var isMatch = value === 'delete';
confirmBtn.disabled = !isMatch || isSubmitting;
confirmInput.classList.toggle('matched', isMatch);
});
// Confirm button
confirmBtn.addEventListener('click', handleConfirm);
// Click outside modal content to close
modalElement.addEventListener('click', function(e) {
if (e.target === modalElement) {
hide();
}
});
}
/**
* Show the delete modal for a given series.
* @param {string} key - Series key
*/
function show(key) {
console.info('[DeleteModal] Opening for key:', key);
// Get series info from SeriesManager if available
var seriesData = null;
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
seriesData = AniWorld.SeriesManager.findByKey(key);
}
currentKey = key;
currentSeriesName = seriesData ? (seriesData.name || key) : key;
// Populate modal
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
// Reset state
confirmInput.value = '';
confirmInput.classList.remove('matched');
confirmBtn.disabled = true;
isSubmitting = false;
errorElement.classList.add('hidden');
errorElement.textContent = '';
deleteDbCheckbox.checked = true;
deleteFolderCheckbox.checked = false;
document.getElementById('delete-folder-warning').style.display = 'none';
// Show modal
modalElement.classList.remove('hidden');
confirmInput.focus();
}
/**
* Hide the modal and reset state.
*/
function hide() {
if (isSubmitting) return; // Don't close while submitting
modalElement.classList.add('hidden');
currentKey = null;
currentSeriesName = null;
}
/**
* Handle confirm button click.
*/
async function handleConfirm() {
if (confirmBtn.disabled || isSubmitting) return;
var deleteDb = deleteDbCheckbox.checked;
var deleteFolder = deleteFolderCheckbox.checked;
var confirmText = confirmInput.value.trim();
// Validate confirm text
if (confirmText !== 'delete') {
showError('You must type exactly "delete" to confirm.');
return;
}
// Validate at least one option selected
if (!deleteDb && !deleteFolder) {
showError('Please select at least one delete option.');
return;
}
isSubmitting = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
errorElement.classList.add('hidden');
console.info('[DeleteModal] Initiating delete:', {
key: currentKey,
delete_database: deleteDb,
delete_folder: deleteFolder
});
try {
var response = await AniWorld.ApiClient.request(
API + '/' + encodeURIComponent(currentKey),
{
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_database: deleteDb,
delete_folder: deleteFolder,
confirm_text: confirmText
})
}
);
if (!response) {
// Auth failure already redirected
hide();
return;
}
if (response.status === 401) {
AniWorld.Auth.removeToken();
window.location.href = '/login';
hide();
return;
}
if (response.status === 400) {
var body = await response.json().catch(function() { return {}; });
showError(body.detail || 'Invalid request: ' + response.status);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
if (response.status === 404) {
showError('Series not found: ' + currentKey);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
return;
}
if (!response.ok) {
var text = await response.text();
showError('Delete failed: HTTP ' + response.status + ' ' + text);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
var result = await response.json();
console.info('[DeleteModal] Delete succeeded:', result);
// Show success message based on what was deleted
var msgParts = [];
if (result.deleted_from_database) msgParts.push('removed from database');
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
var successMsg = msgParts.length > 0
? msgParts.join('; ')
: 'Delete completed.';
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
hide();
// Remove the card from the grid directly
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(currentKey);
}
} catch (err) {
console.error('[DeleteModal] Delete request failed:', err);
showError('Network error: ' + (err && err.message ? err.message : String(err)));
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
}
/**
* Show an error message in the modal.
* @param {string} msg
*/
function showError(msg) {
errorElement.textContent = msg;
errorElement.classList.remove('hidden');
}
// Public API
return {
init: init,
show: show,
hide: hide
};
})();

View File

@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
renderSeries();
}
/**
* Remove a series from the local data arrays and re-render the grid.
* Called after a successful delete or when receiving series_deleted WS event.
* @param {string} key - Series key to remove
*/
function removeSeries(key) {
if (!key) return;
var removedFromData = false;
var removedFromFiltered = false;
if (seriesData) {
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
if (dataIdx >= 0) {
seriesData.splice(dataIdx, 1);
removedFromData = true;
}
}
if (filteredSeriesData) {
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
if (filteredIdx >= 0) {
filteredSeriesData.splice(filteredIdx, 1);
removedFromFiltered = true;
}
}
if (removedFromData || removedFromFiltered) {
console.info('[SeriesManager] Removed series from local state:', key);
renderSeries();
} else {
console.warn('[SeriesManager] Series not found in local state:', key);
}
}
// Public API
return {
init: init,
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey
updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries
};
})();

View File

@@ -160,6 +160,15 @@ AniWorld.IndexSocketHandler = (function() {
}
});
// Series deleted event — remove the card from the UI
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
console.info('[SocketHandler] Series deleted:', data);
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(data.key);
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
}
});
// Download events
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true;

View File

@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
// Series events
SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted',
// Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',

View File

@@ -693,6 +693,7 @@
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body>