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

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

View File

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

View File

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