diff --git a/src/server/api/anime.py b/src/server/api/anime.py index a0de00c..e4dd71f 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -17,12 +17,14 @@ from src.server.exceptions import ( ServerError, ValidationError, ) +from src.server.models.anime import AnimeMetadataUpdate from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.background_loader_service import BackgroundLoaderService from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates from src.server.utils.dependencies import ( get_anime_service, get_background_loader_service, + get_database_session, get_optional_database_session, get_series_app, require_auth, @@ -1186,3 +1188,75 @@ async def get_anime( # 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/api/nfo.py b/src/server/api/nfo.py index 6e1108b..cd3bfd7 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -16,6 +16,11 @@ from src.core.entities.series import Serie from src.core.SeriesApp import SeriesApp from src.core.services.nfo_factory import get_nfo_factory from src.core.services.nfo_service import NFOService +from src.core.services.nfo_repair_service import ( + REQUIRED_TAGS, + NfoRepairService, + find_missing_tags, +) from src.core.services.tmdb_client import TMDBAPIError from src.server.models.nfo import ( MediaDownloadRequest, @@ -27,8 +32,10 @@ from src.server.models.nfo import ( NFOContentResponse, NFOCreateRequest, NFOCreateResponse, + NfoDiagnosticsResponse, NFOMissingResponse, NFOMissingSeries, + NfoRepairResponse, ) from src.server.utils.dependencies import get_series_app, require_auth from src.server.utils.media import check_media_files, get_media_file_paths @@ -808,3 +815,142 @@ async def download_media( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to download media: {str(e)}" ) from e + + +@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse) +async def get_nfo_diagnostics( + serie_key: str, + _auth: dict = Depends(require_auth), + series_app: SeriesApp = Depends(get_series_app), +) -> NfoDiagnosticsResponse: + """Get NFO diagnostics showing missing required tags. + + Args: + serie_key: Series key identifier + _auth: Authentication dependency + series_app: SeriesApp instance + + Returns: + NfoDiagnosticsResponse with has_nfo, missing_tags, required_tags + + Raises: + HTTPException 404: Series not found + """ + serie = None + for s in series_app.list.GetList(): + if getattr(s, "key", None) == serie_key: + serie = s + break + + if not serie: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series with key '{serie_key}' not found", + ) + + serie_folder = serie.ensure_folder_with_year() + folder_path = Path(settings.anime_directory) / serie_folder + nfo_path = folder_path / "tvshow.nfo" + + required_tag_names = list(REQUIRED_TAGS.values()) + + if not nfo_path.exists(): + return NfoDiagnosticsResponse( + has_nfo=False, + nfo_path=None, + missing_tags=required_tag_names, + required_tags=required_tag_names, + ) + + missing = find_missing_tags(nfo_path) + + return NfoDiagnosticsResponse( + has_nfo=True, + nfo_path=str(nfo_path), + missing_tags=missing, + required_tags=required_tag_names, + ) + + +@router.post("/{serie_key}/repair", response_model=NfoRepairResponse) +async def repair_nfo( + serie_key: str, + _auth: dict = Depends(require_auth), + series_app: SeriesApp = Depends(get_series_app), + nfo_service: NFOService = Depends(get_nfo_service), +) -> NfoRepairResponse: + """Repair or recreate NFO file for a series. + + Detects missing required tags and re-fetches metadata from TMDB. + + Args: + serie_key: Series key identifier + _auth: Authentication dependency + series_app: SeriesApp instance + nfo_service: NFO service for TMDB operations + + Returns: + NfoRepairResponse with success status and details + + Raises: + HTTPException 404: Series not found + HTTPException 400: Cannot repair (e.g., no TMDB data available) + """ + serie = None + for s in series_app.list.GetList(): + if getattr(s, "key", None) == serie_key: + serie = s + break + + if not serie: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series with key '{serie_key}' not found", + ) + + serie_folder = serie.ensure_folder_with_year() + folder_path = Path(settings.anime_directory) / serie_folder + nfo_path = folder_path / "tvshow.nfo" + + # Get missing tags before repair for reporting + missing_before = find_missing_tags(nfo_path) if nfo_path.exists() else list(REQUIRED_TAGS.values()) + + try: + repair_service = NfoRepairService(nfo_service) + + if nfo_path.exists(): + repaired = await repair_service.repair_series(folder_path, serie_folder) + if not repaired: + return NfoRepairResponse( + success=True, + message="NFO is already complete, no repair needed", + repaired_tags=[], + ) + else: + # No NFO exists — create new one + await nfo_service.create_tvshow_nfo( + serie_name=serie.name, + serie_folder=serie_folder, + download_poster=True, + download_logo=True, + download_fanart=True, + ) + + return NfoRepairResponse( + success=True, + message=f"NFO repaired successfully. Fixed {len(missing_before)} missing tags.", + repaired_tags=missing_before, + ) + + except TMDBAPIError as e: + logger.warning("NFO repair failed for '%s': %s", serie_key, e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot repair NFO: {str(e)}. Ensure TMDB ID is set.", + ) from e + except Exception as e: + logger.error("NFO repair error for '%s': %s", serie_key, e, exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to repair NFO: {str(e)}", + ) from e diff --git a/src/server/models/anime.py b/src/server/models/anime.py index 5c693ae..3e995c4 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -83,6 +83,30 @@ 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/models/nfo.py b/src/server/models/nfo.py index 801cee5..0839415 100644 --- a/src/server/models/nfo.py +++ b/src/server/models/nfo.py @@ -355,3 +355,29 @@ class NFOMissingResponse(BaseModel): ..., description="List of series missing NFO" ) + + +class NfoDiagnosticsResponse(BaseModel): + """Response for NFO diagnostics showing missing required tags.""" + + has_nfo: bool = Field(..., description="Whether tvshow.nfo exists") + nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists") + missing_tags: List[str] = Field( + default_factory=list, + description="List of missing required tag names" + ) + required_tags: List[str] = Field( + default_factory=list, + description="All required tag names for reference" + ) + + +class NfoRepairResponse(BaseModel): + """Response after NFO repair attempt.""" + + success: bool = Field(..., description="Whether repair succeeded") + message: str = Field(..., description="Human-readable result message") + repaired_tags: List[str] = Field( + default_factory=list, + description="Tags that were missing before repair" + ) diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css index ee681ad..e378d7f 100644 --- a/src/server/web/static/css/components/modals.css +++ b/src/server/web/static/css/components/modals.css @@ -268,3 +268,205 @@ gap: 4px; } } + +/* ============================================================================ + Context Menu + ============================================================================ */ + +.context-menu { + position: fixed; + z-index: 1500; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + box-shadow: var(--shadow-elevated); + min-width: 180px; + padding: var(--spacing-xs) 0; + animation: contextMenuFadeIn 0.12s ease-out; +} + +@keyframes contextMenuFadeIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.context-menu-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + color: var(--color-text-primary); + font-size: var(--font-size-body); + transition: background-color 0.1s ease; +} + +.context-menu-item:hover { + background-color: var(--color-hover); +} + +.context-menu-item i { + width: 16px; + text-align: center; + color: var(--color-text-secondary); +} + +/* ============================================================================ + Edit Metadata Modal + ============================================================================ */ + +.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-md); +} + +.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); +} + +.nfo-status-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: var(--font-size-caption); + font-weight: 600; + width: fit-content; +} + +.nfo-status-badge.nfo-complete { + background: rgba(46, 204, 113, 0.15); + color: var(--color-success, #2ecc71); +} + +.nfo-status-badge.nfo-incomplete { + background: rgba(243, 156, 18, 0.15); + color: var(--color-warning, #f39c12); +} + +.nfo-status-badge.nfo-missing { + background: rgba(231, 76, 60, 0.15); + color: var(--color-error, #e74c3c); +} + +.missing-tags-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-top: var(--spacing-xs); +} + +.missing-tag-chip { + display: inline-block; + padding: 2px 8px; + background: var(--color-background-subtle); + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.nfo-all-good { + color: var(--color-success, #2ecc71); + font-size: var(--font-size-caption); + margin: 0; +} + +.nfo-error { + color: var(--color-error, #e74c3c); + font-size: var(--font-size-caption); + margin: 0; +} + +.repair-hint { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.btn-repair { + align-self: flex-start; + margin-top: var(--spacing-sm); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + + diff --git a/src/server/web/static/js/index/app-init.js b/src/server/web/static/js/index/app-init.js index 6a979fa..3387f02 100644 --- a/src/server/web/static/js/index/app-init.js +++ b/src/server/web/static/js/index/app-init.js @@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() { AniWorld.Search.init(); AniWorld.ScanManager.init(); AniWorld.ConfigManager.init(); + AniWorld.ContextMenu.init(); // Bind global events bindGlobalEvents(); diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js new file mode 100644 index 0000000..272dbfa --- /dev/null +++ b/src/server/web/static/js/index/context-menu.js @@ -0,0 +1,123 @@ +/** + * AniWorld - Context Menu Component + * + * Right-click context menu for anime series cards. + * Provides quick access to edit metadata. + * + * Dependencies: ui-utils.js, edit-modal.js + */ + +var AniWorld = window.AniWorld || {}; + +AniWorld.ContextMenu = (function() { + 'use strict'; + + let menuElement = null; + let currentSeriesKey = null; + + /** + * Initialize the context menu system. + * Attaches global dismissal listeners. + */ + function init() { + // Dismiss on click outside + document.addEventListener('click', function(e) { + if (menuElement && !menuElement.contains(e.target)) { + hide(); + } + }); + + // Dismiss on Escape + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + hide(); + } + }); + + // Dismiss on scroll or resize + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + + // Attach context menu via event delegation on the series grid + const grid = document.getElementById('series-grid'); + if (grid) { + grid.addEventListener('contextmenu', function(e) { + const card = e.target.closest('.series-card'); + if (card) { + e.preventDefault(); + const key = card.getAttribute('data-key'); + if (key) { + show(e, key); + } + } + }); + } + } + + /** + * Show context menu at cursor position. + * @param {MouseEvent} event - The contextmenu event + * @param {string} seriesKey - The series key to operate on + */ + function show(event, seriesKey) { + hide(); // Remove any existing menu first + + currentSeriesKey = seriesKey; + + menuElement = document.createElement('div'); + menuElement.className = 'context-menu'; + menuElement.innerHTML = ` +
+ `; + + document.body.appendChild(menuElement); + + // Position within viewport bounds + const x = event.clientX; + const y = event.clientY; + const menuRect = menuElement.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let posX = x; + let posY = y; + + if (x + menuRect.width > viewportWidth) { + posX = viewportWidth - menuRect.width - 8; + } + if (y + menuRect.height > viewportHeight) { + posY = viewportHeight - menuRect.height - 8; + } + + menuElement.style.left = posX + 'px'; + menuElement.style.top = posY + 'px'; + + // Attach action handlers + menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() { + hide(); + if (AniWorld.EditModal) { + AniWorld.EditModal.open(currentSeriesKey); + } + }); + } + + /** + * Hide and remove the context menu from DOM. + */ + function hide() { + if (menuElement) { + menuElement.remove(); + menuElement = null; + currentSeriesKey = null; + } + } + + return { + init: init, + show: show, + hide: hide + }; +})(); diff --git a/src/server/web/static/js/index/edit-modal.js b/src/server/web/static/js/index/edit-modal.js new file mode 100644 index 0000000..16a8d70 --- /dev/null +++ b/src/server/web/static/js/index/edit-modal.js @@ -0,0 +1,450 @@ +/** + * 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'; + + 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 { + // Find series data from the local series list + const seriesData = findSeriesData(seriesKey); + + originalData = { + key: seriesKey, + tmdb_id: seriesData ? seriesData.tmdb_id : null, + tvdb_id: seriesData ? seriesData.tvdb_id : null, + }; + + // Populate form fields + setFieldValue('edit-key', originalData.key); + setFieldValue('edit-tmdb-id', originalData.tmdb_id || ''); + setFieldValue('edit-tvdb-id', originalData.tvdb_id || ''); + + // 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(); + } + + /** + * 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'); + + 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'; + } + } + + 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 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'; + } + } + + // ---- 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 overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null; + const keyInput = document.getElementById('edit-key'); + + 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 (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 }); + } + } + + 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/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js index a4acda5..294b9f4 100644 --- a/src/server/web/static/js/index/series-manager.js +++ b/src/server/web/static/js/index/series-manager.js @@ -392,6 +392,22 @@ AniWorld.SeriesManager = (function() { return seriesData; } + /** + * Update a series key in the local data arrays after rename. + * @param {string} oldKey - The previous key + * @param {string} newKey - The new key + */ + function updateSeriesKey(oldKey, newKey) { + if (seriesData) { + var s = seriesData.find(function(item) { return item.key === oldKey; }); + if (s) s.key = newKey; + } + if (filteredSeriesData) { + var fs = filteredSeriesData.find(function(item) { return item.key === oldKey; }); + if (fs) fs.key = newKey; + } + } + /** * Get filtered series data * @returns {Array} Filtered series data array @@ -543,6 +559,7 @@ AniWorld.SeriesManager = (function() { getFilteredSeriesData: getFilteredSeriesData, findByKey: findByKey, updateSeriesLoadingStatus: updateSeriesLoadingStatus, - updateSingleSeries: updateSingleSeries + updateSingleSeries: updateSingleSeries, + updateSeriesKey: updateSeriesKey }; })(); diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index 5ef9ae1..2df8695 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -640,6 +640,80 @@ + + + @@ -665,6 +739,8 @@ + + diff --git a/tests/api/test_anime_edit_endpoints.py b/tests/api/test_anime_edit_endpoints.py new file mode 100644 index 0000000..f3bfddd --- /dev/null +++ b/tests/api/test_anime_edit_endpoints.py @@ -0,0 +1,255 @@ +"""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/api/test_nfo_diagnostics_repair.py b/tests/api/test_nfo_diagnostics_repair.py new file mode 100644 index 0000000..9642942 --- /dev/null +++ b/tests/api/test_nfo_diagnostics_repair.py @@ -0,0 +1,317 @@ +"""Tests for NFO diagnostics and repair API endpoints.""" +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 an authenticated test client with token.""" + 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}"}) + yield client + + +@pytest.fixture +def mock_series_app(): + """Create mock series app with one test series.""" + app_mock = Mock() + serie = Mock() + serie.key = "test-anime" + serie.folder = "Test Anime (2024)" + serie.name = "Test Anime" + serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)") + + list_manager = Mock() + list_manager.GetList = Mock(return_value=[serie]) + app_mock.list = list_manager + + return app_mock + + +@pytest.fixture +def mock_nfo_service(): + """Create mock NFO service.""" + service = Mock() + service.check_nfo_exists = AsyncMock(return_value=False) + service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") + service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") + return service + + +@pytest.fixture +def override_dependencies(mock_series_app, mock_nfo_service): + """Override dependencies for NFO tests.""" + from src.server.api.nfo import get_nfo_service + from src.server.utils.dependencies import get_series_app + + app.dependency_overrides[get_series_app] = lambda: mock_series_app + app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service + + yield + + if get_series_app in app.dependency_overrides: + del app.dependency_overrides[get_series_app] + if get_nfo_service in app.dependency_overrides: + del app.dependency_overrides[get_nfo_service] + + +class TestNfoDiagnostics: + """Tests for GET /api/nfo/{serie_key}/diagnostics.""" + + @pytest.mark.asyncio + async def test_diagnostics_complete_nfo( + self, authenticated_client, override_dependencies + ): + """Test diagnostics with complete NFO returns no missing tags.""" + with patch( + "src.server.api.nfo.Path.exists", return_value=True + ), patch( + "src.server.api.nfo.find_missing_tags", return_value=[] + ): + response = await authenticated_client.get( + "/api/nfo/test-anime/diagnostics" + ) + + assert response.status_code == 200 + data = response.json() + assert data["has_nfo"] is True + assert data["missing_tags"] == [] + assert len(data["required_tags"]) > 0 + + @pytest.mark.asyncio + async def test_diagnostics_missing_tags( + self, authenticated_client, override_dependencies + ): + """Test diagnostics with missing tags returns them.""" + with patch( + "src.server.api.nfo.Path.exists", return_value=True + ), patch( + "src.server.api.nfo.find_missing_tags", + return_value=["plot", "genre", "actor/name"], + ): + response = await authenticated_client.get( + "/api/nfo/test-anime/diagnostics" + ) + + assert response.status_code == 200 + data = response.json() + assert data["has_nfo"] is True + assert "plot" in data["missing_tags"] + assert "genre" in data["missing_tags"] + assert len(data["missing_tags"]) == 3 + + @pytest.mark.asyncio + async def test_diagnostics_no_nfo_file( + self, authenticated_client, override_dependencies + ): + """Test diagnostics when no NFO exists returns all tags as missing.""" + with patch("src.server.api.nfo.Path") as MockPath: + # Make nfo_path.exists() return False + mock_path_instance = Mock() + mock_path_instance.exists.return_value = False + mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance) + MockPath.return_value = mock_path_instance + + response = await authenticated_client.get( + "/api/nfo/test-anime/diagnostics" + ) + + assert response.status_code == 200 + data = response.json() + assert data["has_nfo"] is False + assert len(data["missing_tags"]) > 0 + # All required tags should be listed as missing + assert data["missing_tags"] == data["required_tags"] + + @pytest.mark.asyncio + async def test_diagnostics_nonexistent_series_404( + self, authenticated_client, override_dependencies, mock_series_app + ): + """Test diagnostics for non-existent series returns 404.""" + # Override to return empty list + mock_series_app.list.GetList.return_value = [] + + response = await authenticated_client.get( + "/api/nfo/nonexistent-key/diagnostics" + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_diagnostics_unauthenticated_401(self, client): + """Test diagnostics requires authentication.""" + response = await client.get("/api/nfo/test-anime/diagnostics") + # May return 401 or 503 depending on NFO service availability + assert response.status_code in (401, 503) + + +class TestNfoRepair: + """Tests for POST /api/nfo/{serie_key}/repair.""" + + @pytest.mark.asyncio + async def test_repair_success( + self, authenticated_client, override_dependencies + ): + """Test successful NFO repair.""" + with patch("src.server.api.nfo.Path") as MockPath: + mock_path = Mock() + mock_path.exists.return_value = True + mock_path.__truediv__ = Mock(return_value=mock_path) + MockPath.return_value = mock_path + + with patch( + "src.server.api.nfo.find_missing_tags", + return_value=["plot", "genre"], + ), patch( + "src.server.api.nfo.NfoRepairService" + ) as MockRepairService: + mock_instance = Mock() + mock_instance.repair_series = AsyncMock(return_value=True) + MockRepairService.return_value = mock_instance + + response = await authenticated_client.post( + "/api/nfo/test-anime/repair", json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "2" in data["message"] # "Fixed 2 missing tags" + assert "plot" in data["repaired_tags"] + assert "genre" in data["repaired_tags"] + + @pytest.mark.asyncio + async def test_repair_already_complete( + self, authenticated_client, override_dependencies + ): + """Test repair when NFO is already complete.""" + with patch("src.server.api.nfo.Path") as MockPath: + mock_path = Mock() + mock_path.exists.return_value = True + mock_path.__truediv__ = Mock(return_value=mock_path) + MockPath.return_value = mock_path + + with patch( + "src.server.api.nfo.find_missing_tags", return_value=[] + ), patch( + "src.server.api.nfo.NfoRepairService" + ) as MockRepairService: + mock_instance = Mock() + mock_instance.repair_series = AsyncMock(return_value=False) + MockRepairService.return_value = mock_instance + + response = await authenticated_client.post( + "/api/nfo/test-anime/repair", json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "already complete" in data["message"] + + @pytest.mark.asyncio + async def test_repair_creates_new_nfo( + self, authenticated_client, override_dependencies, mock_nfo_service + ): + """Test repair when no NFO exists creates a new one.""" + with patch("src.server.api.nfo.Path") as MockPath: + mock_path = Mock() + mock_path.exists.return_value = False + mock_path.__truediv__ = Mock(return_value=mock_path) + MockPath.return_value = mock_path + + with patch( + "src.server.api.nfo.REQUIRED_TAGS", + {"./title": "title", "./plot": "plot"}, + ): + response = await authenticated_client.post( + "/api/nfo/test-anime/repair", json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + mock_nfo_service.create_tvshow_nfo.assert_awaited_once() + + @pytest.mark.asyncio + async def test_repair_nonexistent_series_404( + self, authenticated_client, override_dependencies, mock_series_app + ): + """Test repair for non-existent series returns 404.""" + mock_series_app.list.GetList.return_value = [] + + response = await authenticated_client.post( + "/api/nfo/nonexistent-key/repair", json={} + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_repair_unauthenticated_401(self, client): + """Test repair requires authentication.""" + response = await client.post("/api/nfo/test-anime/repair", json={}) + assert response.status_code in (401, 503) + + @pytest.mark.asyncio + async def test_repair_tmdb_api_failure( + self, authenticated_client, override_dependencies + ): + """Test repair handles TMDB API failure gracefully.""" + from src.core.services.tmdb_client import TMDBAPIError + + with patch("src.server.api.nfo.Path") as MockPath: + mock_path = Mock() + mock_path.exists.return_value = True + mock_path.__truediv__ = Mock(return_value=mock_path) + MockPath.return_value = mock_path + + with patch( + "src.server.api.nfo.find_missing_tags", + return_value=["plot"], + ), patch( + "src.server.api.nfo.NfoRepairService" + ) as MockRepairService: + mock_instance = Mock() + mock_instance.repair_series = AsyncMock( + side_effect=TMDBAPIError("No TMDB ID found") + ) + MockRepairService.return_value = mock_instance + + response = await authenticated_client.post( + "/api/nfo/test-anime/repair", json={} + ) + + assert response.status_code == 400 + assert "Cannot repair NFO" in response.json()["detail"] diff --git a/tests/frontend/test_edit_modal.py b/tests/frontend/test_edit_modal.py new file mode 100644 index 0000000..97f3ecd --- /dev/null +++ b/tests/frontend/test_edit_modal.py @@ -0,0 +1,115 @@ +"""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 new file mode 100644 index 0000000..426a7fc --- /dev/null +++ b/tests/unit/test_anime_key_rename.py @@ -0,0 +1,161 @@ +"""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