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 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 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.exceptions import (
|
||||
BadRequestError,
|
||||
@@ -133,6 +134,14 @@ class AnimeSummary(BaseModel):
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
description="The Movie Database (TMDB) ID"
|
||||
@@ -331,6 +340,8 @@ async def list_anime(
|
||||
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
||||
tmdb_id=series_dict.get("tmdb_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
|
||||
|
||||
|
||||
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
|
||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||
|
||||
|
||||
@@ -442,6 +442,23 @@ class AniWorldApp {
|
||||
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
|
||||
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
||||
this.toggleSchedulerTimeInput();
|
||||
@@ -1547,6 +1564,72 @@ class AniWorldApp {
|
||||
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() {
|
||||
try {
|
||||
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
||||
@@ -2344,4 +2427,336 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
// 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) {
|
||||
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 hasNfo = serie.has_nfo || false;
|
||||
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
|
||||
if (serie.key === 'so-im-a-spider-so-what') {
|
||||
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' : '') + ' ' +
|
||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||
(isLoading ? 'loading' : '') + '" ' +
|
||||
@@ -368,9 +400,12 @@ AniWorld.SeriesManager = (function() {
|
||||
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
||||
'</div>' +
|
||||
'<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>') +
|
||||
(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>') +
|
||||
editMetadataBtn +
|
||||
editKeyBtn +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="series-stats">' +
|
||||
|
||||
@@ -640,6 +640,88 @@
|
||||
</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 -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user