refactor: remove Edit Metadata right-click option
Removes the Edit Metadata feature from the anime context menu and
deletes all related backend/frontend code:
Backend:
- DELETE PUT /api/anime/{anime_key} endpoint
- DELETE AnimeMetadataUpdate model and KEY_PATTERN regex
- DELETE MAX_INPUT_LENGTH constant
Frontend:
- DELETE edit-modal.js component
- DELETE edit metadata modal HTML from index.html
- DELETE edit-modal.js script tag
- DELETE edit-only CSS (.edit-modal-content, .edit-section,
.field-error, .input-error, .key-warning, .info-grid,
.info-item, .input-with-action, .btn-fetch-tmdb, .tmdb-*)
- REMOVE 'Edit Metadata' item from context menu (NFO Diagnostics
preserved)
Tests:
- DELETE test_anime_edit_endpoints.py
- DELETE test_edit_modal.py
- DELETE test_anime_key_rename.py
This commit is contained in:
@@ -18,7 +18,6 @@ from src.server.exceptions import (
|
|||||||
)
|
)
|
||||||
from src.server.models.anime import (
|
from src.server.models.anime import (
|
||||||
AnimeDetailsResponse,
|
AnimeDetailsResponse,
|
||||||
AnimeMetadataUpdate,
|
|
||||||
TMDBSearchResult,
|
TMDBSearchResult,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ Note on identifiers:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
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):
|
class EpisodeInfo(BaseModel):
|
||||||
"""Information about a single episode."""
|
"""Information about a single episode."""
|
||||||
@@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel):
|
|||||||
return v
|
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):
|
class SearchRequest(BaseModel):
|
||||||
"""Request payload for searching series."""
|
"""Request payload for searching series."""
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
@@ -504,101 +415,4 @@
|
|||||||
flex-shrink: 0;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* AniWorld - Context Menu Component
|
* AniWorld - Context Menu Component
|
||||||
*
|
*
|
||||||
* Right-click context menu for anime series cards.
|
* 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 || {};
|
var AniWorld = window.AniWorld || {};
|
||||||
@@ -67,10 +67,6 @@ AniWorld.ContextMenu = (function() {
|
|||||||
menuElement = document.createElement('div');
|
menuElement = document.createElement('div');
|
||||||
menuElement.className = 'context-menu';
|
menuElement.className = 'context-menu';
|
||||||
menuElement.innerHTML = `
|
menuElement.innerHTML = `
|
||||||
<div class="context-menu-item" data-action="edit">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
<span>Edit Metadata</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="nfo-diagnostics">
|
<div class="context-menu-item" data-action="nfo-diagnostics">
|
||||||
<i class="fa-solid fa-file-circle-check"></i>
|
<i class="fa-solid fa-file-circle-check"></i>
|
||||||
<span>NFO Diagnostics</span>
|
<span>NFO Diagnostics</span>
|
||||||
@@ -100,13 +96,6 @@ AniWorld.ContextMenu = (function() {
|
|||||||
menuElement.style.top = posY + 'px';
|
menuElement.style.top = posY + 'px';
|
||||||
|
|
||||||
// Attach action handlers
|
// 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
|
// NFO Diagnostics - opens the full NFO settings page
|
||||||
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
|
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
|
||||||
hide();
|
hide();
|
||||||
|
|||||||
@@ -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<Object|null>} 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 = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
|
||||||
resultsContainer.innerHTML = '';
|
|
||||||
resultsContainer.style.display = 'block';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AniWorld.ApiClient.get(
|
|
||||||
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 400) {
|
|
||||||
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await response.json();
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
|
|
||||||
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 '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
|
|
||||||
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
|
|
||||||
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).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 = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
|
|
||||||
updateTmdbFetchButtonState();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
|
||||||
console.error('TMDB search error:', err);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fas fa-search"></i> 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 = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
renderDiagnostics(data);
|
|
||||||
updateRepairButtonState();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
|
||||||
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 = '<p class="nfo-all-good">All required tags present</p>';
|
|
||||||
} else {
|
|
||||||
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
|
||||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
|
||||||
}).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
|
|
||||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
|
||||||
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRepairLoading(loading) {
|
|
||||||
const btn = document.getElementById('btn-repair-nfo');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = loading;
|
|
||||||
btn.innerHTML = loading
|
|
||||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
|
||||||
: '<i class="fa-solid fa-wrench"></i> 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
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -650,129 +650,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Metadata Modal -->
|
|
||||||
<div id="edit-metadata-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-content edit-modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Edit Metadata</h3>
|
|
||||||
<button id="btn-cancel-metadata" class="btn btn-icon">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="edit-metadata-form" onsubmit="return false;">
|
|
||||||
<!-- Series Info Section (Read-only) -->
|
|
||||||
<div class="edit-section">
|
|
||||||
<h4><i class="fa-solid fa-info-circle"></i> Series Info</h4>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Name</label>
|
|
||||||
<span id="edit-name" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Year</label>
|
|
||||||
<span id="edit-year" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Status</label>
|
|
||||||
<span id="edit-status" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Premiered</label>
|
|
||||||
<span id="edit-premiered" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Genres</label>
|
|
||||||
<span id="edit-genres" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Studio</label>
|
|
||||||
<span id="edit-studio" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>Rating</label>
|
|
||||||
<span id="edit-rating" class="info-value">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Identity Section -->
|
|
||||||
<div class="edit-section">
|
|
||||||
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-key">Series Key</label>
|
|
||||||
<input type="text" id="edit-key" class="input-field"
|
|
||||||
placeholder="e.g. attack-on-titan"
|
|
||||||
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
|
|
||||||
<span class="field-error" style="display:none;"></span>
|
|
||||||
</div>
|
|
||||||
<div id="key-change-warning" class="key-warning" style="display:none;">
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
Changing the key will update the primary identifier. This may affect provider linkage.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- External IDs Section -->
|
|
||||||
<div class="edit-section">
|
|
||||||
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-tmdb-id">TMDB ID</label>
|
|
||||||
<div class="input-with-action">
|
|
||||||
<input type="number" id="edit-tmdb-id" class="input-field"
|
|
||||||
placeholder="e.g. 1429" min="1">
|
|
||||||
<button type="button" id="btn-fetch-tmdb" class="btn btn-secondary btn-fetch-tmdb" style="display:none;">
|
|
||||||
<i class="fas fa-search"></i> Fetch from TMDB
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="field-error" style="display:none;"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-tvdb-id">TVDB ID</label>
|
|
||||||
<input type="number" id="edit-tvdb-id" class="input-field"
|
|
||||||
placeholder="e.g. 267440" min="1">
|
|
||||||
<span class="field-error" style="display:none;"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="tmdb-search-results" class="tmdb-search-results" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NFO Status Section -->
|
|
||||||
<div class="edit-section">
|
|
||||||
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
|
||||||
<div class="nfo-diagnostics">
|
|
||||||
<div class="nfo-status-row">
|
|
||||||
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
|
||||||
<span id="nfo-path-display" class="nfo-path-display"></span>
|
|
||||||
</div>
|
|
||||||
<div id="nfo-diagnostics-container">
|
|
||||||
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
|
||||||
</div>
|
|
||||||
<p id="repair-hint" class="repair-hint" style="display:none;">
|
|
||||||
<i class="fa-solid fa-circle-info"></i>
|
|
||||||
No TMDB ID set. Repair will search TMDB by series name.
|
|
||||||
</p>
|
|
||||||
<div class="nfo-actions-row">
|
|
||||||
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
|
||||||
<i class="fa-solid fa-wrench"></i> Repair NFO
|
|
||||||
</button>
|
|
||||||
<a id="btn-open-nfo-diagnostics" class="btn btn-secondary btn-open-diagnostics" href="#" style="display:none; text-decoration: none;">
|
|
||||||
<i class="fa-solid fa-external-link-alt"></i> Full Diagnostics
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="btn-save-metadata" class="btn btn-primary">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<!-- Toast notifications -->
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -799,7 +676,6 @@
|
|||||||
|
|
||||||
<!-- Index Page Modules -->
|
<!-- Index Page Modules -->
|
||||||
<script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
|
<script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
|
||||||
<script src="/static/js/index/edit-modal.js?v={{ static_version('js/index/edit-modal.js') }}"></script>
|
|
||||||
<script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
|
<script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
|
||||||
<script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
|
<script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
|
||||||
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user