diff --git a/src/server/api/anime.py b/src/server/api/anime.py index eaca467..ff7649a 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -18,7 +18,6 @@ from src.server.exceptions import ( ) from src.server.models.anime import ( AnimeDetailsResponse, - AnimeMetadataUpdate, TMDBSearchResult, ) from src.server.services.anime_service import AnimeService, AnimeServiceError @@ -1310,78 +1309,3 @@ async def search_tmdb_for_series( ] -# Maximum allowed input size for security -MAX_INPUT_LENGTH = 100000 # 100KB - - -@router.put("/{anime_key}") -async def update_anime_metadata( - anime_key: str, - body: AnimeMetadataUpdate, - _auth: dict = Depends(require_auth), - db: AsyncSession = Depends(get_database_session), -) -> dict: - """Update anime metadata (key, tmdb_id, tvdb_id). - - Args: - anime_key: Current series key to update - body: Fields to update (all optional) - _auth: Authentication dependency - db: Database session - - Returns: - Updated series metadata - - Raises: - HTTPException 404: Series not found - HTTPException 409: Key conflict (new key already exists) - HTTPException 422: Validation error - """ - series = await AnimeSeriesService.get_by_key(db, anime_key) - if not series: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series with key '{anime_key}' not found", - ) - - updates = {} - - if body.key is not None and body.key != anime_key: - existing = await AnimeSeriesService.get_by_key(db, body.key) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"A series with key '{body.key}' already exists", - ) - updates["key"] = body.key - - if body.tmdb_id is not None: - updates["tmdb_id"] = body.tmdb_id - - if body.tvdb_id is not None: - updates["tvdb_id"] = body.tvdb_id - - if not updates: - return { - "key": series.key, - "tmdb_id": series.tmdb_id, - "tvdb_id": series.tvdb_id, - "message": "No changes", - } - - updated = await AnimeSeriesService.update(db, series.id, **updates) - await db.commit() - - logger.info( - "Updated metadata for '%s': %s", - anime_key, - updates, - ) - - return { - "key": updated.key, - "tmdb_id": updated.tmdb_id, - "tvdb_id": updated.tvdb_id, - "message": "Metadata updated successfully", - } - diff --git a/src/server/models/anime.py b/src/server/models/anime.py index 5b45324..d3b400d 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -10,15 +10,11 @@ Note on identifiers: """ from __future__ import annotations -import re from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field, HttpUrl, field_validator -# Regex pattern for valid series keys (URL-safe, lowercase with hyphens) -KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$') - class EpisodeInfo(BaseModel): """Information about a single episode.""" @@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel): return v -class AnimeMetadataUpdate(BaseModel): - """Request model for updating anime metadata (key, tmdb_id, tvdb_id).""" - - key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)") - tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)") - tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)") - - @field_validator('key', mode='before') - @classmethod - def validate_key_format(cls, v: Optional[str]) -> Optional[str]: - """Validate key is URL-safe lowercase with hyphens only.""" - if v is None: - return v - v = v.strip().lower() - if not v: - raise ValueError("Key cannot be empty") - if not KEY_PATTERN.match(v): - raise ValueError( - "Key must contain only lowercase letters, numbers, and hyphens. " - "Cannot start or end with a hyphen." - ) - return v - - class SearchRequest(BaseModel): """Request payload for searching series.""" diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css index 79c0a1d..fbbdd8c 100644 --- a/src/server/web/static/css/components/modals.css +++ b/src/server/web/static/css/components/modals.css @@ -312,97 +312,8 @@ } /* ============================================================================ - Edit Metadata Modal + NFO Diagnostics ============================================================================ */ - -.edit-modal-content { - max-width: 520px; -} - -.edit-section { - margin-bottom: var(--spacing-lg); - padding-bottom: var(--spacing-lg); - border-bottom: 1px solid var(--color-divider); -} - -.edit-section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.edit-section h4 { - margin: 0 0 var(--spacing-md) 0; - font-size: var(--font-size-body); - font-weight: 600; - color: var(--color-text-primary); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.edit-section h4 i { - color: var(--color-accent); -} - -.form-group { - margin-bottom: var(--spacing-md); -} - -.form-group label { - display: block; - margin-bottom: var(--spacing-xs); - font-size: var(--font-size-caption); - font-weight: 500; - color: var(--color-text-secondary); -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-lg); -} - -/* Edit modal specific input sizing */ -.edit-modal-content .input-field { - width: 100%; - min-width: 140px; -} - -.edit-modal-content .form-group { - min-width: 0; -} - -.edit-modal-content .form-row .form-group { - min-width: 0; -} - -.field-error { - display: block; - margin-top: var(--spacing-xs); - font-size: var(--font-size-caption); - color: var(--color-error, #e74c3c); -} - -.input-error { - border-color: var(--color-error, #e74c3c) !important; -} - -.key-warning { - background: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: var(--border-radius); - padding: var(--spacing-sm) var(--spacing-md); - margin-top: var(--spacing-sm); - font-size: var(--font-size-caption); - color: var(--color-warning, #f39c12); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -/* NFO Diagnostics */ -.nfo-diagnostics { display: flex; flex-direction: column; gap: var(--spacing-sm); @@ -504,101 +415,4 @@ flex-shrink: 0; } -/* Series Info Grid (Read-only display) */ -.info-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--spacing-sm) var(--spacing-lg); -} - -.info-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.info-item label { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - font-weight: 500; -} - -.info-item .info-value { - font-size: var(--font-size-body); - color: var(--color-text-primary); -} - -/* Input with action button */ -.input-with-action { - display: flex; - gap: var(--spacing-sm); - align-items: center; -} - -.input-with-action .input-field { - flex: 1; -} - -.btn-fetch-tmdb { - flex-shrink: 0; - white-space: nowrap; -} - -/* TMDB Search Results */ -.tmdb-search-results { - margin-top: var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--border-radius); - background: var(--color-surface); - max-height: 200px; - overflow-y: auto; -} - -.tmdb-result-item { - padding: var(--spacing-sm) var(--spacing-md); - cursor: pointer; - border-bottom: 1px solid var(--color-divider); - display: flex; - flex-direction: column; - gap: 2px; -} - -.tmdb-result-item:last-child { - border-bottom: none; -} - -.tmdb-result-item:hover { - background: var(--color-background-subtle); -} - -.tmdb-result-title { - font-weight: 500; - color: var(--color-text-primary); -} - -.tmdb-result-overview { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tmdb-error, -.tmdb-no-results, -.tmdb-selected { - padding: var(--spacing-sm) var(--spacing-md); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - margin: 0; -} - -.tmdb-error { - color: var(--color-error, #e74c3c); -} - -.tmdb-selected { - color: var(--color-success, #2ecc71); -} - diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js index 18b3a65..7e43e73 100644 --- a/src/server/web/static/js/index/context-menu.js +++ b/src/server/web/static/js/index/context-menu.js @@ -2,9 +2,9 @@ * AniWorld - Context Menu Component * * Right-click context menu for anime series cards. - * Provides quick access to edit metadata. + * Provides quick access to NFO diagnostics. * - * Dependencies: ui-utils.js, edit-modal.js + * Dependencies: ui-utils.js */ var AniWorld = window.AniWorld || {}; @@ -67,10 +67,6 @@ AniWorld.ContextMenu = (function() { menuElement = document.createElement('div'); menuElement.className = 'context-menu'; menuElement.innerHTML = ` -
- - Edit Metadata -
NFO Diagnostics @@ -100,13 +96,6 @@ AniWorld.ContextMenu = (function() { menuElement.style.top = posY + 'px'; // Attach action handlers - menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() { - hide(); - if (AniWorld.EditModal) { - AniWorld.EditModal.open(currentSeriesKey); - } - }); - // NFO Diagnostics - opens the full NFO settings page menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() { hide(); diff --git a/src/server/web/static/js/index/edit-modal.js b/src/server/web/static/js/index/edit-modal.js deleted file mode 100644 index f57fb17..0000000 --- a/src/server/web/static/js/index/edit-modal.js +++ /dev/null @@ -1,630 +0,0 @@ -/** - * AniWorld - Edit Modal Component - * - * Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id) - * and NFO diagnostics with repair functionality. - * - * Dependencies: api-client.js, ui-utils.js - */ - -var AniWorld = window.AniWorld || {}; - -AniWorld.EditModal = (function() { - 'use strict'; - - const API = AniWorld.Constants ? AniWorld.Constants.API : {}; - - let modalElement = null; - let originalData = null; - let currentKey = null; - - /** - * Open the edit modal for a specific anime series. - * @param {string} seriesKey - The series key to edit - */ - async function open(seriesKey) { - currentKey = seriesKey; - modalElement = document.getElementById('edit-metadata-modal'); - if (!modalElement) return; - - // Show modal - modalElement.classList.remove('hidden'); - - // Reset form state - setLoading(true); - clearErrors(); - hideKeyWarning(); - - try { - // Always fetch fresh data from API for edit modal to ensure accuracy - // This is more reliable than local cache which may be stale or missing - let seriesData = await fetchSeriesDetails(seriesKey); - - // Fallback: try local data if API fails - if (!seriesData) { - seriesData = findSeriesData(seriesKey); - } - - originalData = { - key: seriesKey, - tmdb_id: seriesData ? seriesData.tmdb_id : null, - tvdb_id: seriesData ? seriesData.tvdb_id : null, - name: seriesData ? seriesData.name : seriesKey, - year: seriesData ? seriesData.year : null, - status: seriesData ? seriesData.status : null, - genres: seriesData ? (seriesData.genres || []) : [], - studio: seriesData ? (seriesData.studio || []) : [], - premiered: seriesData ? seriesData.premiered : null, - rating: seriesData ? seriesData.rating : null, - rating_votes: seriesData ? seriesData.rating_votes : null, - }; - - // Populate form fields - setFieldValue('edit-key', originalData.key); - setFieldValue('edit-tmdb-id', originalData.tmdb_id || ''); - setFieldValue('edit-tvdb-id', originalData.tvdb_id || ''); - - // Populate display fields - populateDisplayFields(originalData); - - // Show/hide TMDB fetch button based on whether TMDB ID exists - updateTmdbFetchButtonState(); - - // Load NFO diagnostics - await loadDiagnostics(seriesKey); - - } catch (err) { - AniWorld.UI.showToast('Failed to load series data', 'error'); - console.error('Edit modal load error:', err); - } finally { - setLoading(false); - } - - // Attach event listeners - attachListeners(); - } - - /** - * Fetch series details from the API. - * @param {string} key - Series key - * @returns {Promise} Series details or null on error - */ - async function fetchSeriesDetails(key) { - try { - const response = await AniWorld.ApiClient.get( - '/api/anime/' + encodeURIComponent(key) + '/details' - ); - if (response && response.ok) { - return await response.json(); - } - } catch (err) { - console.error('Failed to fetch series details:', err); - } - return null; - } - - /** - * Populate display-only fields in the modal. - * @param {Object} data - Series data - */ - function populateDisplayFields(data) { - const fields = [ - { id: 'edit-name', value: data.name || '' }, - { id: 'edit-year', value: data.year || '' }, - { id: 'edit-status', value: data.status || '' }, - { id: 'edit-genres', value: (data.genres || []).join(', ') }, - { id: 'edit-studio', value: (data.studio || []).join(', ') }, - { id: 'edit-premiered', value: data.premiered || '' }, - { id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' }, - ]; - - fields.forEach(function(field) { - const el = document.getElementById(field.id); - if (el) el.textContent = field.value; - }); - } - - /** - * Update the TMDB fetch button visibility. - */ - function updateTmdbFetchButtonState() { - const btn = document.getElementById('btn-fetch-tmdb'); - const tmdbValue = getFieldValue('edit-tmdb-id').trim(); - - if (btn) { - btn.style.display = tmdbValue ? 'none' : 'inline-flex'; - } - } - - /** - * Fetch TMDB data for auto-fill. - */ - async function fetchTmdbData() { - const btn = document.getElementById('btn-fetch-tmdb'); - const resultsContainer = document.getElementById('tmdb-search-results'); - if (!btn || !resultsContainer) return; - - // Show loading state - btn.disabled = true; - btn.innerHTML = ' Searching...'; - resultsContainer.innerHTML = ''; - resultsContainer.style.display = 'block'; - - try { - const response = await AniWorld.ApiClient.get( - '/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search' - ); - - if (!response) { - resultsContainer.innerHTML = '

Connection error

'; - return; - } - - if (response.status === 400) { - resultsContainer.innerHTML = '

TMDB not configured

'; - return; - } - - if (!response.ok) { - resultsContainer.innerHTML = '

Search failed

'; - return; - } - - const results = await response.json(); - - if (results.length === 0) { - resultsContainer.innerHTML = '

No matches found

'; - return; - } - - // Render results - resultsContainer.innerHTML = results.slice(0, 5).map(function(r) { - const year = r.year ? ' (' + r.year + ')' : ''; - const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : ''; - return '
' + - '' + escapeHtml(r.title) + year + rating + '' + - '' + escapeHtml(r.overview || '') + '' + - '
'; - }).join(''); - - // Attach click handlers to results - resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) { - item.addEventListener('click', function() { - const tmdbId = this.getAttribute('data-tmdb-id'); - setFieldValue('edit-tmdb-id', tmdbId); - resultsContainer.innerHTML = '

TMDB ID ' + tmdbId + ' selected

'; - updateTmdbFetchButtonState(); - }); - }); - - } catch (err) { - resultsContainer.innerHTML = '

Search failed

'; - console.error('TMDB search error:', err); - } finally { - btn.disabled = false; - btn.innerHTML = ' Fetch from TMDB'; - } - } - - /** - * Close the edit modal and reset state. - */ - function close() { - if (modalElement) { - modalElement.classList.add('hidden'); - } - originalData = null; - currentKey = null; - detachListeners(); - } - - /** - * Save changed metadata to the backend. - */ - async function save() { - clearErrors(); - - const newKey = getFieldValue('edit-key').trim().toLowerCase(); - const tmdbIdStr = getFieldValue('edit-tmdb-id').trim(); - const tvdbIdStr = getFieldValue('edit-tvdb-id').trim(); - - // Validate key - if (!newKey) { - showFieldError('edit-key', 'Key cannot be empty'); - return; - } - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) { - showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens'); - return; - } - - // Validate IDs - const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null; - const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null; - - if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) { - showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number'); - return; - } - if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) { - showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number'); - return; - } - - // Check if key changed — show confirmation - if (newKey !== originalData.key) { - const confirmed = await AniWorld.UI.showConfirmModal( - 'Rename Series Key', - `Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` + - 'This may affect provider linkage. Are you sure?' - ); - if (!confirmed) return; - } - - // Build update payload (only changed fields) - const payload = {}; - if (newKey !== originalData.key) payload.key = newKey; - if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId; - if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId; - - if (Object.keys(payload).length === 0) { - AniWorld.UI.showToast('No changes to save', 'info'); - return; - } - - // Send update - setSaveLoading(true); - try { - const response = await AniWorld.ApiClient.put( - '/api/anime/' + encodeURIComponent(currentKey), - payload - ); - - if (!response) return; - - if (response.ok) { - const result = await response.json(); - AniWorld.UI.showToast('Metadata updated successfully', 'success'); - - // Update local state - const oldKey = currentKey; - currentKey = result.key; - originalData = { - key: result.key, - tmdb_id: result.tmdb_id, - tvdb_id: result.tvdb_id, - }; - - // Update the card in the DOM - updateCardAfterSave(oldKey, result); - - // Update repair button state - updateRepairButtonState(); - - } else if (response.status === 409) { - showFieldError('edit-key', 'A series with this key already exists'); - } else if (response.status === 422) { - const err = await response.json(); - AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error'); - } else { - AniWorld.UI.showToast('Failed to update metadata', 'error'); - } - } catch (err) { - AniWorld.UI.showToast('Connection error. Check your network.', 'error'); - console.error('Save error:', err); - } finally { - setSaveLoading(false); - } - } - - /** - * Trigger NFO repair for the current series. - */ - async function repairNfo() { - setRepairLoading(true); - try { - const response = await AniWorld.ApiClient.post( - '/api/nfo/' + encodeURIComponent(currentKey) + '/repair', - {} - ); - - if (!response) return; - - if (response.ok) { - const result = await response.json(); - AniWorld.UI.showToast(result.message, 'success'); - - // Refresh diagnostics - await loadDiagnostics(currentKey); - } else if (response.status === 400) { - const err = await response.json(); - AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error'); - } else { - AniWorld.UI.showToast('Failed to repair NFO', 'error'); - } - } catch (err) { - AniWorld.UI.showToast('Connection error during repair', 'error'); - console.error('Repair error:', err); - } finally { - setRepairLoading(false); - } - } - - /** - * Load NFO diagnostics for the current series. - * @param {string} key - Series key - */ - async function loadDiagnostics(key) { - const container = document.getElementById('nfo-diagnostics-container'); - if (!container) return; - - try { - const response = await AniWorld.ApiClient.get( - '/api/nfo/' + encodeURIComponent(key) + '/diagnostics' - ); - - if (!response || !response.ok) { - container.innerHTML = '

Failed to load NFO diagnostics

'; - return; - } - - const data = await response.json(); - renderDiagnostics(data); - updateRepairButtonState(); - - } catch (err) { - container.innerHTML = '

Error loading diagnostics

'; - console.error('Diagnostics error:', err); - } - } - - /** - * Render NFO diagnostics data into the modal. - * @param {Object} data - NfoDiagnosticsResponse - */ - function renderDiagnostics(data) { - const badge = document.getElementById('nfo-status-badge'); - const tagsList = document.getElementById('nfo-missing-tags'); - const pathDisplay = document.getElementById('nfo-path-display'); - - if (badge) { - if (!data.has_nfo) { - badge.className = 'nfo-status-badge nfo-missing'; - badge.textContent = 'No NFO File'; - } else if (data.missing_tags.length === 0) { - badge.className = 'nfo-status-badge nfo-complete'; - badge.textContent = 'Complete'; - } else { - badge.className = 'nfo-status-badge nfo-incomplete'; - badge.textContent = data.missing_tags.length + ' Missing'; - } - } - - // Show NFO path if available - if (pathDisplay) { - if (data.nfo_path) { - // Extract just the relative path from the full path - const parts = data.nfo_path.split('/'); - const relativePath = parts.slice(-3).join('/'); // folder/tvshow.nfo - pathDisplay.textContent = relativePath; - pathDisplay.title = data.nfo_path; - } else { - pathDisplay.textContent = ''; - } - } - - if (tagsList) { - if (data.missing_tags.length === 0) { - tagsList.innerHTML = '

All required tags present

'; - } else { - tagsList.innerHTML = data.missing_tags.map(function(tag) { - return '' + escapeHtml(tag) + ''; - }).join(''); - } - } - } - - /** - * Update repair button disabled state based on tmdb_id field. - */ - function updateRepairButtonState() { - const btn = document.getElementById('btn-repair-nfo'); - const hint = document.getElementById('repair-hint'); - const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics'); - const tmdbValue = getFieldValue('edit-tmdb-id').trim(); - - if (btn) { - // Enable repair even without tmdb_id — the service can search by name - btn.disabled = false; - } - if (hint) { - hint.style.display = tmdbValue ? 'none' : 'block'; - } - // Show link to full diagnostics page - if (diagnosticsLink && currentKey) { - diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey); - diagnosticsLink.style.display = 'inline-flex'; - } - } - - // ---- Helpers ---- - - function findSeriesData(key) { - // Access the series data from the series manager if available - if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) { - const allSeries = AniWorld.SeriesManager.getSeriesData(); - if (allSeries) { - return allSeries.find(function(s) { return s.key === key; }); - } - } - return null; - } - - function updateCardAfterSave(oldKey, result) { - const card = document.querySelector('[data-series-id="' + oldKey + '"]'); - if (card) { - card.setAttribute('data-key', result.key); - card.setAttribute('data-series-id', result.key); - // Update checkbox data-key - const checkbox = card.querySelector('.series-checkbox'); - if (checkbox) { - checkbox.setAttribute('data-key', result.key); - } - } - - // Update local series data array - if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) { - AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key); - } - } - - function setFieldValue(id, value) { - const el = document.getElementById(id); - if (el) el.value = value !== null && value !== undefined ? value : ''; - } - - function getFieldValue(id) { - const el = document.getElementById(id); - return el ? el.value : ''; - } - - function showFieldError(fieldId, message) { - const el = document.getElementById(fieldId); - if (el) { - const errorEl = el.parentElement.querySelector('.field-error'); - if (errorEl) { - errorEl.textContent = message; - errorEl.style.display = 'block'; - } - el.classList.add('input-error'); - } - } - - function clearErrors() { - if (!modalElement) return; - modalElement.querySelectorAll('.field-error').forEach(function(el) { - el.style.display = 'none'; - el.textContent = ''; - }); - modalElement.querySelectorAll('.input-error').forEach(function(el) { - el.classList.remove('input-error'); - }); - } - - function hideKeyWarning() { - const warning = document.getElementById('key-change-warning'); - if (warning) warning.style.display = 'none'; - } - - function setLoading(loading) { - const form = document.getElementById('edit-metadata-form'); - if (form) { - form.style.opacity = loading ? '0.5' : '1'; - form.style.pointerEvents = loading ? 'none' : 'auto'; - } - } - - function setSaveLoading(loading) { - const btn = document.getElementById('btn-save-metadata'); - if (btn) { - btn.disabled = loading; - btn.innerHTML = loading - ? ' Saving...' - : ' Save'; - } - } - - function setRepairLoading(loading) { - const btn = document.getElementById('btn-repair-nfo'); - if (btn) { - btn.disabled = loading; - btn.innerHTML = loading - ? ' Repairing...' - : ' Repair NFO'; - } - } - - function escapeHtml(str) { - var div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; - } - - // Event listener management - let listeners = []; - - function attachListeners() { - detachListeners(); - - const saveBtn = document.getElementById('btn-save-metadata'); - const cancelBtn = document.getElementById('btn-cancel-metadata'); - const repairBtn = document.getElementById('btn-repair-nfo'); - const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb'); - const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null; - const keyInput = document.getElementById('edit-key'); - const tmdbInput = document.getElementById('edit-tmdb-id'); - - if (saveBtn) { - var saveFn = function() { save(); }; - saveBtn.addEventListener('click', saveFn); - listeners.push({ el: saveBtn, event: 'click', fn: saveFn }); - } - - if (cancelBtn) { - var cancelFn = function() { close(); }; - cancelBtn.addEventListener('click', cancelFn); - listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn }); - } - - if (repairBtn) { - var repairFn = function() { repairNfo(); }; - repairBtn.addEventListener('click', repairFn); - listeners.push({ el: repairBtn, event: 'click', fn: repairFn }); - } - - if (fetchTmdbBtn) { - var fetchTmdbFn = function() { fetchTmdbData(); }; - fetchTmdbBtn.addEventListener('click', fetchTmdbFn); - listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn }); - } - - if (overlay) { - var overlayFn = function() { close(); }; - overlay.addEventListener('click', overlayFn); - listeners.push({ el: overlay, event: 'click', fn: overlayFn }); - } - - if (keyInput) { - var keyFn = function() { - var warning = document.getElementById('key-change-warning'); - if (warning) { - warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none'; - } - }; - keyInput.addEventListener('input', keyFn); - listeners.push({ el: keyInput, event: 'input', fn: keyFn }); - } - - if (tmdbInput) { - var tmdbFn = function() { - updateTmdbFetchButtonState(); - }; - tmdbInput.addEventListener('input', tmdbFn); - listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn }); - } - } - - function detachListeners() { - listeners.forEach(function(l) { - l.el.removeEventListener(l.event, l.fn); - }); - listeners = []; - } - - return { - open: open, - close: close, - save: save, - repairNfo: repairNfo - }; -})(); diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index f6ef4bb..be30356 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -650,129 +650,6 @@
- - -
@@ -799,7 +676,6 @@ - diff --git a/tests/api/test_anime_edit_endpoints.py b/tests/api/test_anime_edit_endpoints.py deleted file mode 100644 index f3bfddd..0000000 --- a/tests/api/test_anime_edit_endpoints.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint.""" -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from httpx import ASGITransport, AsyncClient - -from src.server.fastapi_app import app -from src.server.services.auth_service import auth_service - - -@pytest.fixture -def reset_auth(): - """Reset auth state before each test.""" - auth_service._hash = None - auth_service._failed = {} - - -@pytest.fixture -async def client(): - """Create async test client.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - - -@pytest.fixture -async def authenticated_client(client): - """Get authenticated client with Bearer token.""" - # Setup auth - await client.post("/api/auth/setup", json={"master_password": "TestPass123!"}) - response = await client.post( - "/api/auth/login", json={"password": "TestPass123!"} - ) - token = response.json()["access_token"] - client.headers["Authorization"] = f"Bearer {token}" - return client - - -@pytest.fixture -def mock_db_session(): - """Create a mock async database session.""" - session = AsyncMock() - session.commit = AsyncMock() - session.flush = AsyncMock() - session.refresh = AsyncMock() - return session - - -@pytest.fixture -def mock_series_in_db(): - """Create a mock AnimeSeries DB record.""" - series = MagicMock() - series.id = 1 - series.key = "test-anime" - series.name = "Test Anime" - series.tmdb_id = 1234 - series.tvdb_id = 5678 - series.folder = "Test Anime (2023)" - return series - - -@pytest.fixture -def override_db_dependency(mock_db_session): - """Override database session dependency.""" - from src.server.utils.dependencies import get_database_session - - app.dependency_overrides[get_database_session] = lambda: mock_db_session - yield mock_db_session - app.dependency_overrides.pop(get_database_session, None) - - -class TestUpdateAnimeMetadata: - """Tests for PUT /api/anime/{anime_key}.""" - - @pytest.mark.asyncio - async def test_update_tmdb_id_success( - self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db - ): - """Test successful tmdb_id update.""" - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - return_value=mock_series_in_db, - ), patch( - "src.server.api.anime.AnimeSeriesService.update", - new_callable=AsyncMock, - ) as mock_update: - mock_series_in_db.tmdb_id = 9999 - mock_update.return_value = mock_series_in_db - - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"tmdb_id": 9999}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["tmdb_id"] == 9999 - assert data["message"] == "Metadata updated successfully" - - @pytest.mark.asyncio - async def test_update_tvdb_id_success( - self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db - ): - """Test successful tvdb_id update.""" - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - return_value=mock_series_in_db, - ), patch( - "src.server.api.anime.AnimeSeriesService.update", - new_callable=AsyncMock, - ) as mock_update: - mock_series_in_db.tvdb_id = 7777 - mock_update.return_value = mock_series_in_db - - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"tvdb_id": 7777}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["tvdb_id"] == 7777 - - @pytest.mark.asyncio - async def test_update_key_success( - self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db - ): - """Test successful key rename.""" - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - ) as mock_get: - # First call finds the series, second call checks uniqueness (returns None) - mock_get.side_effect = [mock_series_in_db, None] - - mock_series_in_db.key = "new-anime-key" - with patch( - "src.server.api.anime.AnimeSeriesService.update", - new_callable=AsyncMock, - return_value=mock_series_in_db, - ): - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"key": "new-anime-key"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["key"] == "new-anime-key" - - @pytest.mark.asyncio - async def test_update_key_conflict_409( - self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db - ): - """Test key rename conflict returns 409.""" - existing_series = MagicMock() - existing_series.key = "existing-key" - - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - ) as mock_get: - # First call finds original series, second call finds conflict - mock_get.side_effect = [mock_series_in_db, existing_series] - - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"key": "existing-key"}, - ) - - assert response.status_code == 409 - assert "already exists" in response.json()["detail"] - - @pytest.mark.asyncio - async def test_update_key_invalid_chars_422( - self, reset_auth, authenticated_client, override_db_dependency - ): - """Test key with invalid characters returns 422.""" - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"key": "Invalid Key With Spaces!"}, - ) - - assert response.status_code == 422 - - @pytest.mark.asyncio - async def test_update_key_empty_422( - self, reset_auth, authenticated_client, override_db_dependency - ): - """Test empty key returns 422.""" - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"key": ""}, - ) - - assert response.status_code == 422 - - @pytest.mark.asyncio - async def test_update_unauthenticated_401(self, reset_auth, client): - """Test unauthenticated access returns 401.""" - response = await client.put( - "/api/anime/test-anime", - json={"tmdb_id": 1234}, - ) - - assert response.status_code == 401 - - @pytest.mark.asyncio - async def test_update_nonexistent_anime_404( - self, reset_auth, authenticated_client, override_db_dependency - ): - """Test update of non-existent anime returns 404.""" - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - return_value=None, - ): - response = await authenticated_client.put( - "/api/anime/nonexistent-key", - json={"tmdb_id": 1234}, - ) - - assert response.status_code == 404 - - @pytest.mark.asyncio - async def test_update_no_changes( - self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db - ): - """Test sending empty body returns no-op response.""" - with patch( - "src.server.api.anime.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - return_value=mock_series_in_db, - ): - response = await authenticated_client.put( - "/api/anime/test-anime", - json={}, - ) - - assert response.status_code == 200 - assert response.json()["message"] == "No changes" - - @pytest.mark.asyncio - async def test_update_negative_tmdb_id_422( - self, reset_auth, authenticated_client, override_db_dependency - ): - """Test negative TMDB ID returns 422.""" - response = await authenticated_client.put( - "/api/anime/test-anime", - json={"tmdb_id": -5}, - ) - - assert response.status_code == 422 diff --git a/tests/frontend/test_edit_modal.py b/tests/frontend/test_edit_modal.py deleted file mode 100644 index 97f3ecd..0000000 --- a/tests/frontend/test_edit_modal.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Frontend tests for the edit metadata modal HTML structure.""" -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from httpx import ASGITransport, AsyncClient - -from src.server.fastapi_app import app -from src.server.services.auth_service import auth_service - - -@pytest.fixture(autouse=True) -def reset_auth(): - """Reset authentication state before each test.""" - original_hash = auth_service._hash - auth_service._hash = None - auth_service._failed.clear() - yield - auth_service._hash = original_hash - auth_service._failed.clear() - - -@pytest.fixture -async def client(): - """Create an async test client.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - - -@pytest.fixture -async def authenticated_client(client): - """Create authenticated client to access index page.""" - await client.post( - "/api/auth/setup", - json={"master_password": "TestPassword123!"} - ) - response = await client.post( - "/api/auth/login", - json={"password": "TestPassword123!"} - ) - token = response.json()["access_token"] - client.headers.update({"Authorization": f"Bearer {token}"}) - # Set cookie for page access - client.cookies.set("access_token", token) - yield client - - -class TestEditModalHtmlPresence: - """Tests verifying edit modal HTML elements exist in index page.""" - - @pytest.mark.asyncio - async def test_index_page_contains_edit_modal(self, authenticated_client): - """Verify #edit-metadata-modal exists in rendered index page.""" - response = await authenticated_client.get("/") - - # Page may redirect or require different auth for HTML pages - if response.status_code == 200: - html = response.text - assert 'id="edit-metadata-modal"' in html - - @pytest.mark.asyncio - async def test_index_page_loads_context_menu_script(self, authenticated_client): - """Verify context-menu.js script tag is present.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert "context-menu.js" in html - - @pytest.mark.asyncio - async def test_index_page_loads_edit_modal_script(self, authenticated_client): - """Verify edit-modal.js script tag is present.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert "edit-modal.js" in html - - @pytest.mark.asyncio - async def test_modal_form_fields_present(self, authenticated_client): - """Verify key, tmdb_id, tvdb_id input fields exist in modal.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert 'id="edit-key"' in html - assert 'id="edit-tmdb-id"' in html - assert 'id="edit-tvdb-id"' in html - - @pytest.mark.asyncio - async def test_nfo_repair_button_present(self, authenticated_client): - """Verify repair NFO button exists in modal.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert 'id="btn-repair-nfo"' in html - - @pytest.mark.asyncio - async def test_save_button_present(self, authenticated_client): - """Verify save button exists in modal.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert 'id="btn-save-metadata"' in html - - @pytest.mark.asyncio - async def test_modal_starts_hidden(self, authenticated_client): - """Verify modal has hidden class by default.""" - response = await authenticated_client.get("/") - - if response.status_code == 200: - html = response.text - assert 'id="edit-metadata-modal" class="modal hidden"' in html diff --git a/tests/unit/test_anime_key_rename.py b/tests/unit/test_anime_key_rename.py deleted file mode 100644 index 426a7fc..0000000 --- a/tests/unit/test_anime_key_rename.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Unit tests for anime key rename logic and validation.""" -import pytest -from pydantic import ValidationError - -from src.server.models.anime import AnimeMetadataUpdate, KEY_PATTERN - - -class TestKeyValidation: - """Tests for AnimeMetadataUpdate key validation.""" - - def test_valid_key_simple(self): - """Test simple valid key.""" - model = AnimeMetadataUpdate(key="attack-on-titan") - assert model.key == "attack-on-titan" - - def test_valid_key_single_char(self): - """Test single character key is valid.""" - model = AnimeMetadataUpdate(key="a") - assert model.key == "a" - - def test_valid_key_numbers(self): - """Test key with numbers.""" - model = AnimeMetadataUpdate(key="86-eighty-six") - assert model.key == "86-eighty-six" - - def test_valid_key_allows_hyphens(self): - """Test hyphens in key are allowed.""" - model = AnimeMetadataUpdate(key="my-anime-key") - assert model.key == "my-anime-key" - - def test_valid_key_normalizes_to_lowercase(self): - """Test key is normalized to lowercase.""" - model = AnimeMetadataUpdate(key="Attack-On-Titan") - assert model.key == "attack-on-titan" - - def test_valid_key_strips_whitespace(self): - """Test key strips leading/trailing whitespace.""" - model = AnimeMetadataUpdate(key=" my-key ") - assert model.key == "my-key" - - def test_invalid_key_spaces(self): - """Test key with spaces is rejected.""" - with pytest.raises(ValidationError) as exc_info: - AnimeMetadataUpdate(key="my anime key") - assert "Key must contain only" in str(exc_info.value) - - def test_invalid_key_uppercase_special(self): - """Test key with special characters is rejected.""" - with pytest.raises(ValidationError) as exc_info: - AnimeMetadataUpdate(key="anime!@#key") - assert "Key must contain only" in str(exc_info.value) - - def test_invalid_key_empty(self): - """Test empty key is rejected.""" - with pytest.raises(ValidationError) as exc_info: - AnimeMetadataUpdate(key="") - assert "cannot be empty" in str(exc_info.value) - - def test_invalid_key_only_whitespace(self): - """Test whitespace-only key is rejected.""" - with pytest.raises(ValidationError) as exc_info: - AnimeMetadataUpdate(key=" ") - assert "cannot be empty" in str(exc_info.value) - - def test_invalid_key_starts_with_hyphen(self): - """Test key starting with hyphen is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(key="-my-key") - - def test_invalid_key_ends_with_hyphen(self): - """Test key ending with hyphen is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(key="my-key-") - - def test_key_none_is_allowed(self): - """Test None key (no change requested) is allowed.""" - model = AnimeMetadataUpdate(key=None) - assert model.key is None - - def test_key_omitted_is_allowed(self): - """Test omitting key entirely is allowed.""" - model = AnimeMetadataUpdate(tmdb_id=1234) - assert model.key is None - - -class TestTmdbIdValidation: - """Tests for tmdb_id validation.""" - - def test_valid_tmdb_id(self): - """Test valid positive TMDB ID.""" - model = AnimeMetadataUpdate(tmdb_id=1429) - assert model.tmdb_id == 1429 - - def test_tmdb_id_none(self): - """Test None tmdb_id is allowed.""" - model = AnimeMetadataUpdate(tmdb_id=None) - assert model.tmdb_id is None - - def test_tmdb_id_negative_rejected(self): - """Test negative tmdb_id is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(tmdb_id=-1) - - def test_tmdb_id_zero_rejected(self): - """Test zero tmdb_id is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(tmdb_id=0) - - -class TestTvdbIdValidation: - """Tests for tvdb_id validation.""" - - def test_valid_tvdb_id(self): - """Test valid positive TVDB ID.""" - model = AnimeMetadataUpdate(tvdb_id=267440) - assert model.tvdb_id == 267440 - - def test_tvdb_id_none(self): - """Test None tvdb_id is allowed.""" - model = AnimeMetadataUpdate(tvdb_id=None) - assert model.tvdb_id is None - - def test_tvdb_id_negative_rejected(self): - """Test negative tvdb_id is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(tvdb_id=-5) - - def test_tvdb_id_zero_rejected(self): - """Test zero tvdb_id is rejected.""" - with pytest.raises(ValidationError): - AnimeMetadataUpdate(tvdb_id=0) - - -class TestKeyPattern: - """Tests for the KEY_PATTERN regex directly.""" - - @pytest.mark.parametrize("key", [ - "a", - "abc", - "attack-on-titan", - "86-eighty-six", - "a1b2c3", - "x", - "1", - ]) - def test_valid_patterns(self, key): - """Test keys that should match the pattern.""" - assert KEY_PATTERN.match(key) is not None - - @pytest.mark.parametrize("key", [ - "-start", - "end-", - "has space", - "UPPER", - "special!char", - "under_score", - "", - ]) - def test_invalid_patterns(self, key): - """Test keys that should not match the pattern.""" - assert KEY_PATTERN.match(key) is None