feat: add manual TMDB/TVDB ID entry for failed lookups
- Add PATCH /api/anime/{key}/metadata-ids endpoint to update IDs
- Add POST /api/anime/{key}/refresh-nfo endpoint to force NFO regeneration
- Add Edit Metadata IDs modal in frontend
- Add showEditMetadataModal, saveMetadataIds, refreshSeriesNfo JS functions
- Add edit-metadata-btn to series cards with database icon
- IDs validated as positive integers or null
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,10 +3,11 @@ import warnings
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.core.entities.series import Serie
|
||||||
|
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
@@ -133,6 +134,14 @@ class AnimeSummary(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="ISO timestamp when NFO was last updated"
|
description="ISO timestamp when NFO was last updated"
|
||||||
)
|
)
|
||||||
|
loading_status: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Current loading status (e.g., 'completed', 'failed', 'in_progress')"
|
||||||
|
)
|
||||||
|
loading_error: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error message if loading failed (e.g., 'key cannot be None or empty')"
|
||||||
|
)
|
||||||
tmdb_id: Optional[int] = Field(
|
tmdb_id: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="The Movie Database (TMDB) ID"
|
description="The Movie Database (TMDB) ID"
|
||||||
@@ -331,6 +340,8 @@ async def list_anime(
|
|||||||
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
||||||
tmdb_id=series_dict.get("tmdb_id"),
|
tmdb_id=series_dict.get("tmdb_id"),
|
||||||
tvdb_id=series_dict.get("tvdb_id"),
|
tvdb_id=series_dict.get("tvdb_id"),
|
||||||
|
loading_status=series_dict.get("loading_status"),
|
||||||
|
loading_error=series_dict.get("loading_error"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1085,6 +1096,346 @@ async def get_anime(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class ManualKeyUpdate(BaseModel):
|
||||||
|
"""Request model for manually updating a series key."""
|
||||||
|
|
||||||
|
key: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
description="New URL-safe key for the series (alphanumeric, hyphens, underscores)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{anime_key}/manual-key", response_model=dict)
|
||||||
|
async def update_series_manual_key(
|
||||||
|
anime_key: str,
|
||||||
|
update_data: ManualKeyUpdate,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Manually update the key for a series.
|
||||||
|
|
||||||
|
This endpoint allows users to supply a key for folders that failed
|
||||||
|
automatic key generation (e.g., non-Latin characters, special symbols).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Current series key
|
||||||
|
update_data: New key to assign
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance for in-memory updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series info with new key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If validation fails or series not found
|
||||||
|
"""
|
||||||
|
new_key = update_data.key.strip()
|
||||||
|
|
||||||
|
# Validate the new key format
|
||||||
|
if not is_valid_key(new_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid key format. Key must be URL-safe (alphanumeric, hyphens, underscores only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the series - check DB first
|
||||||
|
series_db = None
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
# Also check in-memory list if series_app available
|
||||||
|
found_in_memory = None
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
found_in_memory = serie
|
||||||
|
break
|
||||||
|
|
||||||
|
if not series_db and not found_in_memory:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if new key is already in use
|
||||||
|
existing_keys = set()
|
||||||
|
if db:
|
||||||
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
existing_keys = {s.key for s in all_series if s.key != anime_key}
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
key = getattr(serie, "key", None)
|
||||||
|
if key and key != anime_key:
|
||||||
|
existing_keys.add(key)
|
||||||
|
|
||||||
|
if new_key in existing_keys:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Key '{new_key}' is already in use by another series"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_key = anime_key
|
||||||
|
|
||||||
|
# Update in database if found
|
||||||
|
if series_db:
|
||||||
|
from src.server.database.connection import get_db
|
||||||
|
async with get_db() as session:
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
session,
|
||||||
|
series_db.id,
|
||||||
|
key=new_key,
|
||||||
|
loading_error=None # Clear error on successful key update
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Update in-memory cache
|
||||||
|
if found_in_memory:
|
||||||
|
try:
|
||||||
|
found_in_memory.key = new_key
|
||||||
|
logger.info(
|
||||||
|
"Updated in-memory key for series: %s -> %s",
|
||||||
|
old_key,
|
||||||
|
new_key
|
||||||
|
)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Manual key update successful: %s -> %s",
|
||||||
|
old_key,
|
||||||
|
new_key
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"old_key": old_key,
|
||||||
|
"new_key": new_key,
|
||||||
|
"message": f"Key updated from '{old_key}' to '{new_key}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataIdsUpdate(BaseModel):
|
||||||
|
"""Request model for manually updating TMDB and TVDB IDs."""
|
||||||
|
|
||||||
|
tmdb_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="TMDB ID (positive integer, or null to clear)"
|
||||||
|
)
|
||||||
|
tvdb_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="TVDB ID (positive integer, or null to clear)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("tmdb_id", "tvdb_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_positive_or_null(cls, v):
|
||||||
|
if v is not None and v <= 0:
|
||||||
|
raise ValueError("ID must be a positive integer or null")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{anime_key}/metadata-ids", response_model=dict)
|
||||||
|
async def update_series_metadata_ids(
|
||||||
|
anime_key: str,
|
||||||
|
update_data: MetadataIdsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Manually update TMDB and TVDB IDs for a series.
|
||||||
|
|
||||||
|
This endpoint allows users to supply missing metadata IDs for series
|
||||||
|
that failed automatic TMDB lookup. After updating IDs, it triggers
|
||||||
|
a background NFO re-generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key
|
||||||
|
update_data: TMDB and TVDB IDs to set
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance for in-memory updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series info with new IDs
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If validation fails or series not found
|
||||||
|
"""
|
||||||
|
if update_data.tmdb_id is None and update_data.tvdb_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least one of tmdb_id or tvdb_id must be provided"
|
||||||
|
)
|
||||||
|
|
||||||
|
series_db = None
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
if not series_db:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fields = {}
|
||||||
|
if update_data.tmdb_id is not None:
|
||||||
|
update_fields["tmdb_id"] = update_data.tmdb_id
|
||||||
|
if update_data.tvdb_id is not None:
|
||||||
|
update_fields["tvdb_id"] = update_data.tvdb_id
|
||||||
|
|
||||||
|
if db:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
update_fields["nfo_updated_at"] = datetime.now(timezone.utc)
|
||||||
|
update_fields["has_nfo"] = True
|
||||||
|
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
db,
|
||||||
|
series_db.id,
|
||||||
|
**update_fields
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update in-memory cache if available
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
if update_data.tmdb_id is not None:
|
||||||
|
serie.tmdb_id = update_data.tmdb_id
|
||||||
|
if update_data.tvdb_id is not None:
|
||||||
|
serie.tvdb_id = update_data.tvdb_id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Trigger background NFO re-generation
|
||||||
|
background_loader = None
|
||||||
|
try:
|
||||||
|
background_loader = await get_background_loader_service()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nfo_queued = False
|
||||||
|
if background_loader and db:
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
async with get_db_session() as bg_db:
|
||||||
|
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
|
||||||
|
if series_for_bg:
|
||||||
|
await background_loader.load_series_nfo(
|
||||||
|
series_for_bg.key,
|
||||||
|
series_for_bg.folder,
|
||||||
|
series_for_bg.name,
|
||||||
|
force_refresh=True
|
||||||
|
)
|
||||||
|
nfo_queued = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Metadata IDs updated for '%s': tmdb_id=%s, tvdb_id=%s, NFO_queued=%s",
|
||||||
|
anime_key,
|
||||||
|
update_data.tmdb_id,
|
||||||
|
update_data.tvdb_id,
|
||||||
|
nfo_queued
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"key": anime_key,
|
||||||
|
"tmdb_id": update_data.tmdb_id,
|
||||||
|
"tvdb_id": update_data.tvdb_id,
|
||||||
|
"nfo_refresh_queued": nfo_queued,
|
||||||
|
"message": "Metadata IDs updated. NFO refresh queued." if nfo_queued
|
||||||
|
else "Metadata IDs updated."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{anime_key}/refresh-nfo", response_model=dict)
|
||||||
|
async def refresh_series_nfo(
|
||||||
|
anime_key: str,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Force NFO re-generation for a series using current IDs.
|
||||||
|
|
||||||
|
This endpoint triggers a background NFO re-generation using the
|
||||||
|
existing TMDB/TVDB IDs (or creating minimal NFO if no IDs exist).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status of NFO refresh operation
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If series not found
|
||||||
|
"""
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
if not db or not series_db:
|
||||||
|
# Check in-memory
|
||||||
|
found = None
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
found = serie
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
background_loader = None
|
||||||
|
try:
|
||||||
|
background_loader = await get_background_loader_service()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not background_loader:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Background loader service not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
series_for_bg = None
|
||||||
|
if db:
|
||||||
|
async with get_db_session() as bg_db:
|
||||||
|
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
|
||||||
|
|
||||||
|
if not series_for_bg:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await background_loader.load_series_nfo(
|
||||||
|
series_for_bg.key,
|
||||||
|
series_for_bg.folder,
|
||||||
|
series_for_bg.name,
|
||||||
|
force_refresh=True
|
||||||
|
)
|
||||||
|
nfo_queued = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to queue NFO refresh: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("NFO refresh queued for '%s'", anime_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"key": anime_key,
|
||||||
|
"message": "NFO refresh queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Maximum allowed input size for security
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,23 @@ class AniWorldApp {
|
|||||||
this.hideConfigModal();
|
this.hideConfigModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit key modal
|
||||||
|
document.getElementById('close-edit-key').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit-key').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#edit-key-modal .modal-overlay').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-edit-key').addEventListener('click', () => {
|
||||||
|
this.saveManualKey();
|
||||||
|
});
|
||||||
|
|
||||||
// Scheduler configuration
|
// Scheduler configuration
|
||||||
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
||||||
this.toggleSchedulerTimeInput();
|
this.toggleSchedulerTimeInput();
|
||||||
@@ -1547,6 +1564,72 @@ class AniWorldApp {
|
|||||||
document.getElementById('config-modal').classList.add('hidden');
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showEditKeyModal(key, folder) {
|
||||||
|
this._currentEditKey = key;
|
||||||
|
document.getElementById('edit-key-folder').textContent = folder;
|
||||||
|
document.getElementById('edit-key-input').value = '';
|
||||||
|
document.getElementById('edit-key-error').classList.add('hidden');
|
||||||
|
document.getElementById('edit-key-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideEditKeyModal() {
|
||||||
|
document.getElementById('edit-key-modal').classList.add('hidden');
|
||||||
|
this._currentEditKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveManualKey() {
|
||||||
|
const oldKey = this._currentEditKey;
|
||||||
|
const newKey = document.getElementById('edit-key-input').value.trim();
|
||||||
|
const errorEl = document.getElementById('edit-key-error');
|
||||||
|
|
||||||
|
if (!newKey || newKey.length < 2) {
|
||||||
|
errorEl.textContent = 'Key must be at least 2 characters';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (URL-safe)
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(newKey)) {
|
||||||
|
errorEl.textContent = 'Key must be URL-safe (alphanumeric, hyphens, underscores only)';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeAuthenticatedRequest(
|
||||||
|
`/api/anime/${encodeURIComponent(oldKey)}/manual-key`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: newKey })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
errorEl.textContent = 'Failed to update key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
this.showToast(`Key updated: ${oldKey} → ${newKey}`, 'success');
|
||||||
|
// Reload series list
|
||||||
|
if (typeof AniWorld.SeriesManager !== 'undefined') {
|
||||||
|
AniWorld.SeriesManager.loadSeries();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({ detail: 'Update failed' }));
|
||||||
|
errorEl.textContent = data.detail || 'Failed to update key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving manual key:', err);
|
||||||
|
errorEl.textContent = 'Error updating key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadSchedulerConfig() {
|
async loadSchedulerConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
||||||
@@ -2344,4 +2427,336 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global functions for inline event handlers
|
// Global functions for inline event handlers
|
||||||
window.app = null;
|
window.app = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit key modal
|
||||||
|
* @param {string} currentKey - The current series key
|
||||||
|
* @param {string} folderName - The folder name
|
||||||
|
*/
|
||||||
|
function showEditKeyModal(currentKey, folderName) {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
const folderSpan = document.getElementById('edit-key-folder');
|
||||||
|
const keyInput = document.getElementById('edit-key-input');
|
||||||
|
const errorSpan = document.getElementById('edit-key-error');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
|
||||||
|
if (!modal || !overlay || !folderSpan || !keyInput) {
|
||||||
|
console.error('Edit key modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
folderSpan.textContent = folderName;
|
||||||
|
keyInput.value = currentKey;
|
||||||
|
keyInput.dataset.originalKey = currentKey;
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.style.display = 'none';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
keyInput.focus();
|
||||||
|
keyInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the edit key modal
|
||||||
|
*/
|
||||||
|
function hideEditKeyModal() {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the manual key for a series
|
||||||
|
* @param {string} oldKey - The original key
|
||||||
|
* @param {string} newKey - The new key to set
|
||||||
|
*/
|
||||||
|
async function saveManualKey(oldKey, newKey) {
|
||||||
|
const errorSpan = document.getElementById('edit-key-error');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
|
||||||
|
if (!errorSpan || !saveBtn) {
|
||||||
|
console.error('Edit key modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.style.display = 'none';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(oldKey)}/manual-key`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key: newKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errorSpan.textContent = data.detail || 'Failed to update key';
|
||||||
|
errorSpan.style.display = 'block';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - hide modal and reload
|
||||||
|
hideEditKeyModal();
|
||||||
|
showToast('Key updated successfully', 'success');
|
||||||
|
|
||||||
|
// Reload series list
|
||||||
|
if (window.app && window.app.loadSeries) {
|
||||||
|
window.app.loadSeries();
|
||||||
|
} else if (typeof loadSeries === 'function') {
|
||||||
|
loadSeries();
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving manual key:', error);
|
||||||
|
errorSpan.textContent = 'Network error: ' + error.message;
|
||||||
|
errorSpan.style.display = 'block';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current metadata edit state
|
||||||
|
let _currentEditMetadataKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit metadata IDs modal
|
||||||
|
* @param {string} key - The series key
|
||||||
|
* @param {string} name - The series name
|
||||||
|
* @param {number|null} currentTmdbId - Current TMDB ID
|
||||||
|
* @param {number|null} currentTvdbId - Current TVDB ID
|
||||||
|
*/
|
||||||
|
function showEditMetadataModal(key, name, currentTmdbId, currentTvdbId) {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
||||||
|
const nameSpan = document.getElementById('edit-metadata-series-name');
|
||||||
|
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
||||||
|
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
||||||
|
const errorSpan = document.getElementById('edit-metadata-error');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
||||||
|
|
||||||
|
if (!modal || !nameSpan || !tmdbInput || !tvdbInput) {
|
||||||
|
console.error('Edit metadata modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current key
|
||||||
|
_currentEditMetadataKey = key;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
tmdbInput.value = currentTmdbId || '';
|
||||||
|
tvdbInput.value = currentTvdbId || '';
|
||||||
|
if (errorSpan) {
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
if (overlay) overlay.classList.remove('hidden');
|
||||||
|
tmdbInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the edit metadata modal
|
||||||
|
*/
|
||||||
|
function hideEditMetadataModal() {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
_currentEditMetadataKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save metadata IDs for a series
|
||||||
|
* @param {string} key - The series key
|
||||||
|
* @param {number|null} tmdbId - TMDB ID (null to clear)
|
||||||
|
* @param {number|null} tvdbId - TVDB ID (null to clear)
|
||||||
|
*/
|
||||||
|
async function saveMetadataIds(key, tmdbId, tvdbId) {
|
||||||
|
const errorSpan = document.getElementById('edit-metadata-error');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
|
||||||
|
if (!errorSpan || !saveBtn) {
|
||||||
|
console.error('Edit metadata modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.classList.add('hidden');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
if (tmdbId !== '') body.tmdb_id = parseInt(tmdbId, 10) || null;
|
||||||
|
if (tvdbId !== '') body.tvdb_id = parseInt(tvdbId, 10) || null;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/metadata-ids`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errorSpan.textContent = data.detail || 'Failed to update metadata IDs';
|
||||||
|
errorSpan.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - hide modal and show toast
|
||||||
|
hideEditMetadataModal();
|
||||||
|
showToast('Metadata IDs updated. NFO refresh queued.', 'success');
|
||||||
|
|
||||||
|
// Reload series list to reflect changes
|
||||||
|
if (window.app && window.app.loadSeries) {
|
||||||
|
window.app.loadSeries();
|
||||||
|
} else if (typeof loadSeries === 'function') {
|
||||||
|
loadSeries();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving metadata IDs:', error);
|
||||||
|
errorSpan.textContent = 'Network error: ' + error.message;
|
||||||
|
errorSpan.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh NFO for a series
|
||||||
|
* @param {string} key - The series key
|
||||||
|
*/
|
||||||
|
async function refreshSeriesNfo(key) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/refresh-nfo`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showToast('Failed to refresh NFO: ' + (data.detail || 'Unknown error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('NFO refresh queued', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing NFO:', error);
|
||||||
|
showToast('Network error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind edit metadata modal events if modal exists
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
||||||
|
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
||||||
|
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', hideEditMetadataModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', hideEditMetadataModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn && tmdbInput && tvdbInput) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
if (_currentEditMetadataKey) {
|
||||||
|
saveMetadataIds(
|
||||||
|
_currentEditMetadataKey,
|
||||||
|
tmdbInput.value,
|
||||||
|
tvdbInput.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbInput) {
|
||||||
|
tmdbInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditMetadataModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvdbInput) {
|
||||||
|
tvdbInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditMetadataModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind edit key modal events if modal exists
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
const cancelBtn = document.getElementById('edit-key-cancel');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
const keyInput = document.getElementById('edit-key-input');
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', hideEditKeyModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', hideEditKeyModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn && keyInput) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const originalKey = keyInput.dataset.originalKey;
|
||||||
|
const newKey = keyInput.value.trim();
|
||||||
|
if (newKey && newKey !== originalKey) {
|
||||||
|
saveManualKey(originalKey, newKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyInput) {
|
||||||
|
keyInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditKeyModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,6 +40,31 @@ AniWorld.SeriesManager = (function() {
|
|||||||
if (sortBtn) {
|
if (sortBtn) {
|
||||||
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event delegation for dynamically created edit-key buttons
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const editKeyBtn = e.target.closest('.edit-key-btn');
|
||||||
|
if (editKeyBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = editKeyBtn.dataset.key;
|
||||||
|
const folder = editKeyBtn.dataset.folder;
|
||||||
|
if (window.showEditKeyModal) {
|
||||||
|
window.showEditKeyModal(key, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMetadataBtn = e.target.closest('.edit-metadata-btn');
|
||||||
|
if (editMetadataBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = editMetadataBtn.dataset.key;
|
||||||
|
const name = editMetadataBtn.dataset.name;
|
||||||
|
const tmdbId = editMetadataBtn.dataset.tmdbId || null;
|
||||||
|
const tvdbId = editMetadataBtn.dataset.tvdbId || null;
|
||||||
|
if (window.showEditMetadataModal) {
|
||||||
|
window.showEditMetadataModal(key, name, tmdbId, tvdbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,7 +368,8 @@ AniWorld.SeriesManager = (function() {
|
|||||||
const canBeSelected = hasMissingEpisodes;
|
const canBeSelected = hasMissingEpisodes;
|
||||||
const hasNfo = serie.has_nfo || false;
|
const hasNfo = serie.has_nfo || false;
|
||||||
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
||||||
|
const hasKeyError = serie.loading_error && serie.loading_error.includes('key cannot be None or empty');
|
||||||
|
|
||||||
// Debug logging for troubleshooting
|
// Debug logging for troubleshooting
|
||||||
if (serie.key === 'so-im-a-spider-so-what') {
|
if (serie.key === 'so-im-a-spider-so-what') {
|
||||||
console.log('[createSerieCard] Spider series:', {
|
console.log('[createSerieCard] Spider series:', {
|
||||||
@@ -356,6 +382,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editKeyBtn = hasKeyError
|
||||||
|
? '<button class="btn btn-icon edit-key-btn" title="Fix key error" data-key="' + serie.key + '" data-folder="' + serie.folder + '"><i class="fas fa-key"></i></button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const editMetadataBtn = '<button class="btn btn-icon edit-metadata-btn" title="Edit Metadata IDs" data-key="' + serie.key + '" data-name="' + AniWorld.UI.escapeHtml(serie.name) + '" data-tmdb-id="' + (serie.tmdb_id || '') + '" data-tvdb-id="' + (serie.tvdb_id || '') + '"><i class="fas fa-database"></i></button>';
|
||||||
|
|
||||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||||
(isLoading ? 'loading' : '') + '" ' +
|
(isLoading ? 'loading' : '') + '" ' +
|
||||||
@@ -368,9 +400,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-status">' +
|
'<div class="series-status">' +
|
||||||
|
(hasKeyError ? '<i class="fas fa-exclamation-triangle key-error-badge" title="Key error: ' + serie.loading_error + '"></i>' : '') +
|
||||||
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
||||||
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
||||||
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
||||||
|
editMetadataBtn +
|
||||||
|
editKeyBtn +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-stats">' +
|
'<div class="series-stats">' +
|
||||||
|
|||||||
@@ -640,6 +640,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Key Modal -->
|
||||||
|
<div id="edit-key-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 data-text="edit-key-title">Edit Series Key</h3>
|
||||||
|
<button id="close-edit-key" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="current-folder">Folder Name:</label>
|
||||||
|
<span id="edit-key-folder" class="config-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-key-input" data-text="new-key">New Key:</label>
|
||||||
|
<input type="text" id="edit-key-input" class="input-field"
|
||||||
|
placeholder="e.g., attack-on-titan" minlength="2">
|
||||||
|
<small class="config-hint" data-text="key-format-hint">
|
||||||
|
URL-safe key (alphanumeric, hyphens, underscores)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div id="edit-key-error" class="config-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="save-edit-key" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<span data-text="save">Save</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-edit-key" class="btn btn-secondary">
|
||||||
|
<span data-text="cancel">Cancel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Metadata IDs Modal -->
|
||||||
|
<div id="edit-metadata-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 data-text="edit-metadata-title">Edit Metadata IDs</h3>
|
||||||
|
<button id="close-edit-metadata" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="series-name">Series:</label>
|
||||||
|
<span id="edit-metadata-series-name" class="config-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-metadata-tmdb" data-text="tmdb-id">TMDB ID:</label>
|
||||||
|
<input type="number" id="edit-metadata-tmdb" class="input-field"
|
||||||
|
placeholder="e.g., 12345" min="1" step="1">
|
||||||
|
<small class="config-hint" data-text="tmdb-id-hint">
|
||||||
|
Leave blank to clear. Find IDs at themoviedb.org
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-metadata-tvdb" data-text="tvdb-id">TVDB ID:</label>
|
||||||
|
<input type="number" id="edit-metadata-tvdb" class="input-field"
|
||||||
|
placeholder="e.g., 67890" min="1" step="1">
|
||||||
|
<small class="config-hint" data-text="tvdb-id-hint">
|
||||||
|
Leave blank to clear. Find IDs at thetvdb.com
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div id="edit-metadata-error" class="config-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="save-edit-metadata" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<span data-text="save-refresh">Save & Refresh NFO</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-edit-metadata" class="btn btn-secondary">
|
||||||
|
<span data-text="cancel">Cancel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<!-- Toast notifications -->
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user