feat: Add NFO configuration settings (Task 7)

- Added NFOConfig model with TMDB API key, auto-create, media downloads, image size settings
- Created NFO settings section in UI with form fields and validation
- Implemented nfo-config.js module for loading, saving, and testing TMDB connection
- Added TMDB API key validation endpoint (POST /api/config/tmdb/validate)
- Integrated NFO config into AppConfig and ConfigUpdate models
- Added 5 unit tests for NFO config model validation
- Added API test for TMDB validation endpoint
- All 16 config model tests passing, all 10 config API tests passing
- Documented in docs/task7_status.md (100% complete)
This commit is contained in:
2026-01-16 19:33:23 +01:00
parent ecfa8d3c10
commit 120b26b9f7
8 changed files with 635 additions and 0 deletions

View File

@@ -371,3 +371,59 @@ def reset_config(
detail=f"Failed to reset config: {e}"
) from e
@router.post("/tmdb/validate", response_model=Dict[str, Any])
async def validate_tmdb_key(
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
) -> Dict[str, Any]:
"""Validate TMDB API key by making a test request.
Args:
api_key_data: Dictionary with 'api_key' field
auth: Authentication token (required)
Returns:
Validation result with success status and message
"""
import aiohttp
api_key = api_key_data.get("api_key", "").strip()
if not api_key:
return {
"valid": False,
"message": "API key is required"
}
try:
# Test the API key with a simple configuration request
url = f"https://api.themoviedb.org/3/configuration?api_key={api_key}"
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=timeout) as response:
if response.status == 200:
return {
"valid": True,
"message": "TMDB API key is valid"
}
elif response.status == 401:
return {
"valid": False,
"message": "Invalid API key"
}
else:
return {
"valid": False,
"message": f"TMDB API error: {response.status}"
}
except aiohttp.ClientError as e:
return {
"valid": False,
"message": f"Connection error: {str(e)}"
}
except Exception as e:
return {
"valid": False,
"message": f"Validation error: {str(e)}"
}

View File

@@ -54,6 +54,43 @@ class LoggingConfig(BaseModel):
return lvl
class NFOConfig(BaseModel):
"""NFO metadata configuration."""
tmdb_api_key: Optional[str] = Field(
default=None, description="TMDB API key for metadata scraping"
)
auto_create: bool = Field(
default=False, description="Auto-create NFO files for new series"
)
update_on_scan: bool = Field(
default=False, description="Update existing NFO files on rescan"
)
download_poster: bool = Field(
default=True, description="Download poster.jpg"
)
download_logo: bool = Field(
default=True, description="Download logo.png"
)
download_fanart: bool = Field(
default=True, description="Download fanart.jpg"
)
image_size: str = Field(
default="original", description="Image size (original or w500)"
)
@field_validator("image_size")
@classmethod
def validate_image_size(cls, v: str) -> str:
allowed = {"original", "w500"}
size = (v or "").lower()
if size not in allowed:
raise ValueError(
f"invalid image size: {v}. Must be 'original' or 'w500'"
)
return size
class ValidationResult(BaseModel):
"""Result of a configuration validation attempt."""
@@ -77,6 +114,7 @@ class AppConfig(BaseModel):
)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig)
nfo: NFOConfig = Field(default_factory=NFOConfig)
other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings"
)
@@ -114,6 +152,7 @@ class ConfigUpdate(BaseModel):
scheduler: Optional[SchedulerConfig] = None
logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None
other: Optional[Dict[str, object]] = None
def apply_to(self, current: AppConfig) -> AppConfig:
@@ -128,6 +167,8 @@ class ConfigUpdate(BaseModel):
data["logging"] = self.logging.model_dump()
if self.backup is not None:
data["backup"] = self.backup.model_dump()
if self.nfo is not None:
data["nfo"] = self.nfo.model_dump()
if self.other is not None:
merged = dict(current.other or {})
merged.update(self.other)

View File

@@ -53,6 +53,9 @@ AniWorld.ConfigManager = (function() {
// Main configuration
bindMainEvents();
// NFO configuration
bindNFOEvents();
// Status panel
const closeStatus = document.getElementById('close-status');
if (closeStatus) {
@@ -115,6 +118,21 @@ AniWorld.ConfigManager = (function() {
}
}
/**
* Bind NFO config events
*/
function bindNFOEvents() {
const saveNFO = document.getElementById('save-nfo-config');
if (saveNFO) {
saveNFO.addEventListener('click', AniWorld.NFOConfig.save);
}
const testTMDB = document.getElementById('test-tmdb-connection');
if (testTMDB) {
testTMDB.addEventListener('click', AniWorld.NFOConfig.testTMDBConnection);
}
}
/**
* Bind main configuration events
*/
@@ -197,6 +215,7 @@ AniWorld.ConfigManager = (function() {
await AniWorld.SchedulerConfig.load();
await AniWorld.LoggingConfig.load();
await AniWorld.AdvancedConfig.load();
await AniWorld.NFOConfig.load();
modal.classList.remove('hidden');
} catch (error) {

View File

@@ -0,0 +1,169 @@
/**
* AniWorld - NFO Config Module
*
* Handles NFO metadata configuration settings.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.NFOConfig = (function() {
'use strict';
const API = AniWorld.Constants.API;
/**
* Load NFO configuration from server
*/
async function load() {
try {
const config = await AniWorld.ApiClient.request(API.CONFIG);
if (config && config.nfo) {
const nfo = config.nfo;
// TMDB API Key
const tmdbKey = document.getElementById('tmdb-api-key');
if (tmdbKey && nfo.tmdb_api_key) {
tmdbKey.value = nfo.tmdb_api_key;
}
// Auto-create NFO
const autoCreate = document.getElementById('nfo-auto-create');
if (autoCreate) {
autoCreate.checked = nfo.auto_create || false;
}
// Update on scan
const updateOnScan = document.getElementById('nfo-update-on-scan');
if (updateOnScan) {
updateOnScan.checked = nfo.update_on_scan || false;
}
// Download options
const downloadPoster = document.getElementById('nfo-download-poster');
if (downloadPoster) {
downloadPoster.checked = nfo.download_poster !== false;
}
const downloadLogo = document.getElementById('nfo-download-logo');
if (downloadLogo) {
downloadLogo.checked = nfo.download_logo !== false;
}
const downloadFanart = document.getElementById('nfo-download-fanart');
if (downloadFanart) {
downloadFanart.checked = nfo.download_fanart !== false;
}
// Image size
const imageSize = document.getElementById('nfo-image-size');
if (imageSize && nfo.image_size) {
imageSize.value = nfo.image_size;
}
}
} catch (error) {
console.error('Error loading NFO config:', error);
AniWorld.UI.showToast('Failed to load NFO configuration', 'error');
}
}
/**
* Save NFO configuration
*/
async function save() {
try {
AniWorld.UI.showLoading('Saving NFO configuration...');
// Get form values
const tmdbKey = document.getElementById('tmdb-api-key');
const autoCreate = document.getElementById('nfo-auto-create');
const updateOnScan = document.getElementById('nfo-update-on-scan');
const downloadPoster = document.getElementById('nfo-download-poster');
const downloadLogo = document.getElementById('nfo-download-logo');
const downloadFanart = document.getElementById('nfo-download-fanart');
const imageSize = document.getElementById('nfo-image-size');
// Validate TMDB API key is provided if auto-create is enabled
if (autoCreate && autoCreate.checked && (!tmdbKey || !tmdbKey.value.trim())) {
AniWorld.UI.hideLoading();
AniWorld.UI.showToast('TMDB API key is required when auto-create is enabled', 'error');
return;
}
const nfoConfig = {
tmdb_api_key: tmdbKey ? tmdbKey.value.trim() : null,
auto_create: autoCreate ? autoCreate.checked : false,
update_on_scan: updateOnScan ? updateOnScan.checked : false,
download_poster: downloadPoster ? downloadPoster.checked : true,
download_logo: downloadLogo ? downloadLogo.checked : true,
download_fanart: downloadFanart ? downloadFanart.checked : true,
image_size: imageSize ? imageSize.value : 'original'
};
// Save configuration
const response = await AniWorld.ApiClient.request(
API.CONFIG,
{
method: 'PUT',
body: JSON.stringify({ nfo: nfoConfig })
}
);
if (response) {
AniWorld.UI.showToast('NFO configuration saved successfully', 'success');
} else {
throw new Error('Failed to save configuration');
}
} catch (error) {
console.error('Error saving NFO config:', error);
AniWorld.UI.showToast('Failed to save NFO configuration: ' + error.message, 'error');
} finally {
AniWorld.UI.hideLoading();
}
}
/**
* Test TMDB API connection
*/
async function testTMDBConnection() {
try {
const tmdbKey = document.getElementById('tmdb-api-key');
if (!tmdbKey || !tmdbKey.value.trim()) {
AniWorld.UI.showToast('Please enter a TMDB API key first', 'warning');
return;
}
AniWorld.UI.showLoading('Testing TMDB connection...');
const response = await AniWorld.ApiClient.request(
'/api/config/tmdb/validate',
{
method: 'POST',
body: JSON.stringify({ api_key: tmdbKey.value.trim() })
}
);
if (response && response.valid) {
AniWorld.UI.showToast('TMDB API key is valid!', 'success');
} else {
const message = response && response.message ? response.message : 'Invalid API key';
AniWorld.UI.showToast('TMDB validation failed: ' + message, 'error');
}
} catch (error) {
console.error('Error testing TMDB connection:', error);
AniWorld.UI.showToast('Failed to test TMDB connection: ' + error.message, 'error');
} finally {
AniWorld.UI.hideLoading();
}
}
// Public API
return {
load: load,
save: save,
testTMDBConnection: testTMDBConnection
};
})();

View File

@@ -349,6 +349,85 @@
</div>
</div>
<!-- NFO Metadata Configuration -->
<div class="config-section">
<h4 data-text="nfo-config">NFO Metadata Settings</h4>
<div class="config-item">
<label for="tmdb-api-key" data-text="tmdb-api-key">TMDB API Key:</label>
<input type="text" id="tmdb-api-key" placeholder="Enter your TMDB API key" class="input-field">
<small class="config-hint" data-text="tmdb-api-hint">
Required for NFO metadata. Get your API key from <a href="https://www.themoviedb.org/settings/api" target="_blank">TMDB</a>
</small>
</div>
<div class="config-item">
<label class="checkbox-label">
<input type="checkbox" id="nfo-auto-create">
<span class="checkbox-custom"></span>
<span data-text="nfo-auto-create">Auto-create NFO files</span>
</label>
<small class="config-hint" data-text="nfo-auto-create-hint">
Automatically create NFO metadata when downloading new series
</small>
</div>
<div class="config-item">
<label class="checkbox-label">
<input type="checkbox" id="nfo-update-on-scan">
<span class="checkbox-custom"></span>
<span data-text="nfo-update-on-scan">Update NFO on rescan</span>
</label>
<small class="config-hint" data-text="nfo-update-hint">
Refresh existing NFO files when rescanning library
</small>
</div>
<div class="config-item">
<h5 data-text="media-downloads">Media File Downloads</h5>
<label class="checkbox-label">
<input type="checkbox" id="nfo-download-poster" checked>
<span class="checkbox-custom"></span>
<span data-text="download-poster">Download poster.jpg</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="nfo-download-logo" checked>
<span class="checkbox-custom"></span>
<span data-text="download-logo">Download logo.png</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="nfo-download-fanart" checked>
<span class="checkbox-custom"></span>
<span data-text="download-fanart">Download fanart.jpg</span>
</label>
</div>
<div class="config-item">
<label for="nfo-image-size" data-text="image-size">Image Quality:</label>
<select id="nfo-image-size" class="input-field">
<option value="original">Original (Best Quality)</option>
<option value="w500">Medium (w500)</option>
</select>
<small class="config-hint" data-text="image-size-hint">
Original provides best quality but larger file sizes
</small>
</div>
<div class="config-actions">
<button id="save-nfo-config" class="btn btn-primary">
<i class="fas fa-save"></i>
<span data-text="save-nfo-config">Save NFO Settings</span>
</button>
<button id="test-tmdb-connection" class="btn btn-secondary">
<i class="fas fa-plug"></i>
<span data-text="test-tmdb">Test TMDB Connection</span>
</button>
</div>
</div>
<!-- Configuration Management -->
<div class="config-section">
<h4 data-text="config-management">Configuration Management</h4>
@@ -463,6 +542,7 @@
<script src="/static/js/index/logging-config.js"></script>
<script src="/static/js/index/advanced-config.js"></script>
<script src="/static/js/index/main-config.js"></script>
<script src="/static/js/index/nfo-config.js"></script>
<script src="/static/js/index/config-manager.js"></script>
<script src="/static/js/index/socket-handler.js"></script>
<script src="/static/js/index/app-init.js"></script>