added delete option
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.ContextMenu.init();
|
||||
AniWorld.DeleteModal.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
317
src/server/web/static/js/index/delete-modal.js
Normal file
317
src/server/web/static/js/index/delete-modal.js
Normal 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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user