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:
2026-05-28 18:38:34 +02:00
parent 33f63ca304
commit 30858f441c
4 changed files with 886 additions and 3 deletions

View File

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

View File

@@ -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();
}
});
}
});

View File

@@ -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">' +

View File

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