diff --git a/Docs/TESTING.md b/Docs/TESTING.md
index d923887..1a77c7e 100644
--- a/Docs/TESTING.md
+++ b/Docs/TESTING.md
@@ -1,104 +1,33 @@
-# Testing Documentation
-## Document Purpose
+### Testing FolderNamingService
-This document describes the testing strategy, guidelines, and practices for the Aniworld project.
-
-### What This Document Contains
-
-- **Testing Strategy**: Overall approach to quality assurance
-- **Test Categories**: Unit, integration, API, performance, security tests
-- **Test Structure**: Organization of test files and directories
-- **Writing Tests**: Guidelines for writing effective tests
-- **Fixtures and Mocking**: Shared test utilities and mock patterns
-- **Running Tests**: Commands and configurations
-- **Coverage Requirements**: Minimum coverage thresholds
-- **CI/CD Integration**: How tests run in automation
-- **Test Data Management**: Managing test fixtures and data
-- **Best Practices**: Do's and don'ts for testing
-
-### What This Document Does NOT Contain
-
-- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
-- Security audit procedures (see [SECURITY.md](SECURITY.md))
-- Bug tracking and issue management
-- Performance benchmarking results
-
-### Target Audience
-
-- Developers writing tests
-- QA Engineers
-- CI/CD Engineers
-- Code reviewers
-
----
-
-## Sections to Document
-
-1. Testing Philosophy
- - Test pyramid approach
- - Quality gates
-2. Test Categories
- - Unit Tests (`tests/unit/`)
- - Integration Tests (`tests/integration/`)
- - API Tests (`tests/api/`)
- - Frontend Tests (`tests/frontend/`)
- - Performance Tests (`tests/performance/`)
- - Security Tests (`tests/security/`)
-3. Test Structure and Naming
- - File naming conventions
- - Test function naming
- - Test class organization
-4. Running Tests
- - pytest commands
- - Running specific tests
- - Verbose output
- - Coverage reports
-5. Fixtures and Conftest
- - Shared fixtures
- - Database fixtures
- - Mock services
-6. Mocking Guidelines
- - What to mock
- - Mock patterns
- - External service mocks
-
-### Mocking the Download Queue
-
-Use `MockQueueRepository` for testing download queue functionality:
-
-```python
-from src.server.models.download import DownloadItem, EpisodeIdentifier
-
-class MockQueueRepository:
- def __init__(self):
- self._items: Dict[str, DownloadItem] = {}
-```
-
-### Testing SetupService
-
-SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
+FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
Key methods tested:
+- `_build_target_folder()` — constructs safe "Title (YYYY)" names, strips existing year suffix first (prevents double-year like "Naruto (1999) (1999)")
+- `_folder_has_year()` — detects existing `(YYYY)` pattern
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
- `_extract_title_from_folder_name()` — strips year suffix
-- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
```python
-@pytest.mark.asyncio
-async def test_returns_key_when_single_exact_match(self):
- """Search returns 1 result with same name → returns key."""
- mock_series_app = AsyncMock()
- mock_series_app.search.return_value = [
- {'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
- ]
+# Safe: repeated calls never accumulate years
+r1 = FolderNamingService._build_target_folder("Naruto", 1999)
+r2 = FolderNamingService._build_target_folder(r1, 1999)
+assert r1 == r2 == "Naruto (1999)"
- with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
- result = await SetupService._resolve_key_via_search("Attack on Titan")
-
- assert result == 'attack-on-titan'
+# Safe: existing year is replaced, not appended
+result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
+assert result == "Naruto (1999)"
+assert "2020" not in result
```
+The service is also tested end-to-end with mocked filesystem and database:
+- Renames folder and updates DB when year is missing from folder name
+- Skips rename when folder already has a year
+- Skips rename when DB has no year
+- Skips when target folder already exists on disk
+- Safety guard detects and skips if target folder year mismatches DB year
+
### Mocking aiohttp Sessions
When testing code that uses `aiohttp.ClientSession`:
diff --git a/src/server/api/scheduler.py b/src/server/api/scheduler.py
index 3a65031..ce110a9 100644
--- a/src/server/api/scheduler.py
+++ b/src/server/api/scheduler.py
@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
"schedule_time": config.schedule_time,
"schedule_days": config.schedule_days,
"auto_download_after_rescan": config.auto_download_after_rescan,
+ "nfo_scan_after_rescan": config.nfo_scan_after_rescan,
+ "image_scan_after_rescan": config.image_scan_after_rescan,
+ "folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
},
"status": {
"is_running": runtime.get("is_running", False),
diff --git a/src/server/models/config.py b/src/server/models/config.py
index 8db3ccf..4cf7764 100644
--- a/src/server/models/config.py
+++ b/src/server/models/config.py
@@ -52,6 +52,12 @@ class SchedulerConfig(BaseModel):
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
"from TMDB after a scheduled rescan completes.",
)
+ folder_naming_after_nfo_scan: bool = Field(
+ default=True,
+ description="Fix missing years in folder names after NFO refresh. "
+ "Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
+ "the year from the database record.",
+ )
# Legacy alias fields — read via Pydantic alias
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
diff --git a/src/server/services/folder_naming_service.py b/src/server/services/folder_naming_service.py
new file mode 100644
index 0000000..a5ebe37
--- /dev/null
+++ b/src/server/services/folder_naming_service.py
@@ -0,0 +1,205 @@
+"""Folder naming service for fixing missing years in anime folder names."""
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+import shutil
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+import structlog
+
+from src.config.settings import settings
+from src.server.database.connection import get_db_session as _get_db_session
+from src.server.database.service import AnimeSeriesService
+
+logger = structlog.get_logger(__name__)
+
+
+@dataclass
+class FolderRenameResult:
+ key: str
+ old_folder: str
+ new_folder: Optional[str]
+ success: bool
+ skipped: bool = False
+ reason: Optional[str] = None
+
+
+@dataclass
+class FolderNamingReport:
+ total: int
+ renamed: int
+ skipped: int
+ errors: int
+ results: List[FolderRenameResult]
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "total": self.total,
+ "renamed": self.renamed,
+ "skipped": self.skipped,
+ "errors": self.errors,
+ "results": [
+ {
+ "key": r.key,
+ "old_folder": r.old_folder,
+ "new_folder": r.new_folder,
+ "success": r.success,
+ "skipped": r.skipped,
+ "reason": r.reason,
+ }
+ for r in self.results
+ ],
+ }
+
+
+class FolderNamingService:
+ def __init__(self) -> None:
+ self._is_running = False
+ self._lock = asyncio.Lock()
+
+ async def run(self) -> FolderNamingReport:
+ async with self._lock:
+ if self._is_running:
+ logger.warning("FolderNamingService.run() called while already running")
+ return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
+ self._is_running = True
+
+ try:
+ logger.info("FolderNamingService: starting folder naming scan")
+ results: List[FolderRenameResult] = []
+
+ async with _get_db_session() as db:
+ all_series = await AnimeSeriesService.get_all(db)
+
+ for series in all_series:
+ result = await self._process_series(series)
+ results.append(result)
+
+ renamed = sum(1 for r in results if r.success and not r.skipped)
+ skipped = sum(1 for r in results if r.skipped)
+ errors = sum(1 for r in results if not r.skipped and not r.success)
+
+ report = FolderNamingReport(
+ total=len(results),
+ renamed=renamed,
+ skipped=skipped,
+ errors=errors,
+ results=results,
+ )
+ logger.info(
+ "FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
+ report.total, report.renamed, report.skipped, report.errors,
+ )
+ return report
+ finally:
+ self._is_running = False
+
+ async def _process_series(self, series) -> FolderRenameResult:
+ key = series.key
+ folder = series.folder or ""
+ year = getattr(series, "year", None)
+
+ if year is None:
+ return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
+
+ if self._folder_has_year(folder):
+ return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
+
+ target_folder = self._build_target_folder(folder, year)
+
+ # Safety: re-extract year from target to prevent double-year
+ if target_folder != folder:
+ extracted = self._extract_year_from_folder_name(target_folder)
+ if extracted != year:
+ logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
+ return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
+
+ return await self._execute_rename(series, folder, target_folder)
+
+ async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
+ key = series.key
+
+ if old_folder == target_folder:
+ return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
+
+ anime_dir = settings.anime_directory
+ old_path = os.path.join(anime_dir, old_folder)
+ target_path = os.path.join(anime_dir, target_folder)
+
+ if not os.path.isdir(old_path):
+ return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
+
+ if os.path.isdir(target_path):
+ return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
+
+ try:
+ shutil.move(old_path, target_path)
+ logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
+
+ # Update in-memory cache
+ try:
+ from src.server.SeriesApp import get_series_app
+ series_app = get_series_app()
+ if key in series_app.list.keyDict:
+ series_app.list.keyDict[key].folder = target_folder
+ except Exception as exc:
+ logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
+
+ # Update database
+ async with _get_db_session() as db:
+ db_series = await AnimeSeriesService.get_by_key(db, key)
+ if db_series:
+ await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
+ logger.debug("Updated DB folder for %s to %s", key, target_folder)
+
+ return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
+
+ except Exception as exc:
+ logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
+ return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
+
+ # Static helpers — public for direct testing
+ @staticmethod
+ def _folder_has_year(folder_name: str) -> bool:
+ if not folder_name:
+ return False
+ return bool(re.search(r"\(\d{4}\)", folder_name))
+
+ @staticmethod
+ def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
+ if not folder_name:
+ return None
+ match = re.search(r"\((\d{4})\)", folder_name)
+ if match:
+ try:
+ year = int(match.group(1))
+ if 1900 <= year <= 2100:
+ return year
+ except ValueError:
+ pass
+ return None
+
+ @staticmethod
+ def _extract_title_from_folder_name(folder_name: str) -> str:
+ return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
+
+ @staticmethod
+ def _build_target_folder(folder_name: str, year: int) -> str:
+ title = FolderNamingService._extract_title_from_folder_name(folder_name)
+ return f"{title} ({year})"
+
+
+_folder_naming_service: Optional[FolderNamingService] = None
+
+def get_folder_naming_service() -> FolderNamingService:
+ global _folder_naming_service
+ if _folder_naming_service is None:
+ _folder_naming_service = FolderNamingService()
+ return _folder_naming_service
+
+def reset_folder_naming_service() -> None:
+ global _folder_naming_service
+ _folder_naming_service = None
diff --git a/src/server/services/scheduler/scheduler_service.py b/src/server/services/scheduler/scheduler_service.py
index e5208ae..a386b8c 100644
--- a/src/server/services/scheduler/scheduler_service.py
+++ b/src/server/services/scheduler/scheduler_service.py
@@ -268,6 +268,9 @@ class SchedulerService:
"image_scan_after_rescan": (
self._config.image_scan_after_rescan if self._config else True
),
+ "folder_naming_after_nfo_scan": (
+ self._config.folder_naming_after_nfo_scan if self._config else True
+ ),
"last_run": (
self._last_scan_time.isoformat()
if self._last_scan_time
@@ -404,7 +407,18 @@ class SchedulerService:
logger.error("NFO scan failed: %s", exc, exc_info=True)
await self._broadcast("nfo_scan_error", {"error": str(exc)})
- # 3. Auto-download (if enabled)
+ # 3. Folder naming (if enabled, runs after NFO scan)
+ if self._config and self._config.folder_naming_after_nfo_scan:
+ if self._config.nfo_scan_after_rescan:
+ # Only run if NFO scan was also enabled (depends on year in DB)
+ try:
+ naming_result = await self._run_folder_naming()
+ await self._broadcast("folder_naming_completed", naming_result.to_dict())
+ except Exception as exc:
+ logger.error("Folder naming failed: %s", exc, exc_info=True)
+ await self._broadcast("folder_naming_error", {"error": str(exc)})
+
+ # 4. Auto-download (if enabled)
if self._config and self._config.auto_download_after_rescan:
try:
queued = await self._run_auto_download()
@@ -413,7 +427,7 @@ class SchedulerService:
logger.error("Auto-download failed: %s", exc, exc_info=True)
await self._broadcast("auto_download_error", {"error": str(exc)})
- # 4. Image scan (if enabled)
+ # 5. Image scan (if enabled)
if self._config and self._config.image_scan_after_rescan:
try:
image_result = await self._run_image_scan()
@@ -536,6 +550,13 @@ class SchedulerService:
)
return result
+ async def _run_folder_naming(self) -> Any:
+ """Run folder naming fix to add missing years to folder names."""
+ from src.server.services.folder_naming_service import get_folder_naming_service
+ service = get_folder_naming_service()
+ logger.info("Starting folder naming scan...")
+ return await service.run()
+
async def _run_auto_download(self) -> int:
"""Queue and start downloads for all series with missing episodes."""
from src.server.models.download import EpisodeIdentifier
diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js
index 843cd83..9201441 100644
--- a/src/server/web/static/js/app.js
+++ b/src/server/web/static/js/app.js
@@ -1,2355 +1,2360 @@
-/**
- * AniWorld Manager - Main JavaScript Application
- * Implements Fluent UI design principles with modern web app functionality
- */
-
-class AniWorldApp {
- constructor() {
- this.socket = null;
- this.selectedSeries = new Set(); // Uses 'key' as identifier
- this.seriesData = []; // Series objects with 'key' as primary identifier
- this.filteredSeriesData = [];
- this.isConnected = false;
- this.isDownloading = false;
- this.isPaused = false;
- this.localization = new Localization();
- this.showMissingOnly = false;
- this.sortAlphabetical = false;
-
- this.init();
- }
-
- async init() {
- await this.checkAuthentication();
- this.initSocket();
- this.bindEvents();
- this.loadSeries();
- this.initTheme();
- this.updateConnectionStatus();
- // Check scan status on page load (in case socket connect event is delayed)
- this.checkActiveScanStatus();
- }
-
- async checkAuthentication() {
- // Don't check authentication if we're already on login or setup pages
- const currentPath = window.location.pathname;
- if (currentPath === '/login' || currentPath === '/setup') {
- return;
- }
-
- try {
- // First check if we have a token
- const token = localStorage.getItem('access_token');
- console.log('checkAuthentication: token exists =', !!token);
-
- if (!token) {
- console.log('checkAuthentication: No token found, redirecting to /login');
- window.location.href = '/login';
- return;
- }
-
- // Build request with token
- const headers = {
- 'Authorization': `Bearer ${token}`
- };
-
- const response = await fetch('/api/auth/status', { headers });
- console.log('checkAuthentication: response status =', response.status);
-
- if (!response.ok) {
- console.log('checkAuthentication: Response not OK, status =', response.status);
- throw new Error(`HTTP ${response.status}`);
- }
-
- const data = await response.json();
- console.log('checkAuthentication: data =', data);
-
- if (!data.configured) {
- // No master password set, redirect to setup
- console.log('checkAuthentication: Not configured, redirecting to /setup');
- window.location.href = '/setup';
- return;
- }
-
- if (!data.authenticated) {
- // Not authenticated, redirect to login
- console.log('checkAuthentication: Not authenticated, redirecting to /login');
- localStorage.removeItem('access_token');
- localStorage.removeItem('token_expires_at');
- window.location.href = '/login';
- return;
- }
-
- // User is authenticated, show logout button
- console.log('checkAuthentication: Authenticated successfully');
- const logoutBtn = document.getElementById('logout-btn');
- if (logoutBtn) {
- logoutBtn.style.display = 'block';
- }
- } catch (error) {
- console.error('Authentication check failed:', error);
- // On error, clear token and redirect to login
- localStorage.removeItem('access_token');
- localStorage.removeItem('token_expires_at');
- window.location.href = '/login';
- }
- }
-
- async logout() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/auth/logout', { method: 'POST' });
-
- // Clear tokens from localStorage
- localStorage.removeItem('access_token');
- localStorage.removeItem('token_expires_at');
-
- if (response && response.ok) {
- const data = await response.json();
- if (data.status === 'ok') {
- this.showToast('Logged out successfully', 'success');
- } else {
- this.showToast('Logged out', 'success');
- }
- } else {
- // Even if the API fails, we cleared the token locally
- this.showToast('Logged out', 'success');
- }
-
- setTimeout(() => {
- window.location.href = '/login';
- }, 1000);
- } catch (error) {
- console.error('Logout error:', error);
- // Clear token even on error
- localStorage.removeItem('access_token');
- localStorage.removeItem('token_expires_at');
- this.showToast('Logged out', 'success');
- setTimeout(() => {
- window.location.href = '/login';
- }, 1000);
- }
- }
-
- toggleMissingOnlyFilter() {
- this.showMissingOnly = !this.showMissingOnly;
- const button = document.getElementById('show-missing-only');
-
- button.setAttribute('data-active', this.showMissingOnly);
- button.classList.toggle('active', this.showMissingOnly);
-
- const icon = button.querySelector('i');
- const text = button.querySelector('span');
-
- if (this.showMissingOnly) {
- icon.className = 'fas fa-filter-circle-xmark';
- text.textContent = 'Show All Series';
- } else {
- icon.className = 'fas fa-filter';
- text.textContent = 'Missing Episodes Only';
- }
-
- this.applyFiltersAndSort();
- this.renderSeries();
- this.clearSelection(); // Clear selection when filter changes
- }
-
- toggleAlphabeticalSort() {
- this.sortAlphabetical = !this.sortAlphabetical;
- const button = document.getElementById('sort-alphabetical');
-
- button.setAttribute('data-active', this.sortAlphabetical);
- button.classList.toggle('active', this.sortAlphabetical);
-
- const icon = button.querySelector('i');
- const text = button.querySelector('span');
-
- if (this.sortAlphabetical) {
- icon.className = 'fas fa-sort-alpha-up';
- text.textContent = 'Default Sort';
- } else {
- icon.className = 'fas fa-sort-alpha-down';
- text.textContent = 'A-Z Sort';
- }
-
- this.applyFiltersAndSort();
- this.renderSeries();
- }
-
- initSocket() {
- this.socket = io();
-
- // Handle initial connection message from server
- this.socket.on('connected', (data) => {
- console.log('WebSocket connection confirmed', data);
- });
-
- this.socket.on('connect', () => {
- this.isConnected = true;
- console.log('Connected to server');
-
- // Subscribe to rooms for targeted updates
- // Valid rooms: downloads, queue, scan, system, errors
- this.socket.join('scan');
- this.socket.join('downloads');
- this.socket.join('queue');
-
- this.showToast(this.localization.getText('connected-server'), 'success');
- this.updateConnectionStatus();
-
- // Check if a scan is currently in progress (e.g., after page reload)
- this.checkActiveScanStatus();
- });
-
- this.socket.on('disconnect', () => {
- this.isConnected = false;
- console.log('Disconnected from server');
- this.showToast(this.localization.getText('disconnected-server'), 'warning');
- this.updateConnectionStatus();
- });
-
- // Scan events - handle new detailed scan progress overlay
- this.socket.on('scan_started', (data) => {
- console.log('Scan started:', data);
- this.showScanProgressOverlay(data);
- this.updateProcessStatus('rescan', true);
- });
-
- this.socket.on('scan_progress', (data) => {
- console.log('Scan progress:', data);
- this.updateScanProgressOverlay(data);
- });
-
- // Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend)
- const handleScanComplete = (data) => {
- console.log('Scan completed:', data);
- this.hideScanProgressOverlay(data);
- this.showToast('Scan completed successfully', 'success');
- this.updateProcessStatus('rescan', false);
- this.loadSeries();
- };
- this.socket.on('scan_completed', handleScanComplete);
- this.socket.on('scan_complete', handleScanComplete);
-
- // Handle both 'scan_error' (legacy) and 'scan_failed' (new backend)
- const handleScanError = (data) => {
- this.hideStatus();
- this.showToast(`Scan error: ${data.message || data.error}`, 'error');
- this.updateProcessStatus('rescan', false, true);
- };
- this.socket.on('scan_error', handleScanError);
- this.socket.on('scan_failed', handleScanError);
-
- // Scheduled scan events
- this.socket.on('scheduled_rescan_started', () => {
- this.showToast('Scheduled rescan started', 'info');
- this.updateProcessStatus('rescan', true);
- });
-
- this.socket.on('scheduled_rescan_completed', (data) => {
- this.showToast('Scheduled rescan completed successfully', 'success');
- this.updateProcessStatus('rescan', false);
- this.loadSeries();
- });
-
- this.socket.on('scheduled_rescan_error', (data) => {
- this.showToast(`Scheduled rescan error: ${data.error}`, 'error');
- this.updateProcessStatus('rescan', false, true);
- });
-
- this.socket.on('scheduled_rescan_skipped', (data) => {
- this.showToast(`Scheduled rescan skipped: ${data.reason}`, 'warning');
- });
-
- this.socket.on('auto_download_started', (data) => {
- this.showToast('Auto-download started after scheduled rescan', 'info');
- this.updateProcessStatus('download', true);
- });
-
- this.socket.on('auto_download_error', (data) => {
- this.showToast(`Auto-download error: ${data.error}`, 'error');
- this.updateProcessStatus('download', false, true);
- });
-
- // Download events
- this.socket.on('download_started', (data) => {
- this.isDownloading = true;
- this.isPaused = false;
- this.updateProcessStatus('download', true);
- this.showDownloadQueue(data);
- this.showStatus(`Starting download of ${data.total_series} series...`, true, true);
- });
-
- this.socket.on('download_progress', (data) => {
- let status = '';
- let percent = 0;
-
- if (data.progress !== undefined) {
- percent = data.progress;
- status = `Downloading: ${percent.toFixed(1)}%`;
-
- // Add speed information if available
- if (data.speed_mbps && data.speed_mbps > 0) {
- status += ` (${data.speed_mbps.toFixed(1)} Mbps)`;
- }
-
- // Add ETA information if available
- if (data.eta_seconds && data.eta_seconds > 0) {
- const eta = this.formatETA(data.eta_seconds);
- status += ` - ETA: ${eta}`;
- }
- } else if (data.total_bytes) {
- percent = ((data.downloaded_bytes || 0) / data.total_bytes * 100);
- status = `Downloading: ${percent.toFixed(1)}%`;
- } else if (data.downloaded_mb !== undefined) {
- status = `Downloaded: ${data.downloaded_mb.toFixed(1)} MB`;
- } else {
- status = `Downloading: ${data.percent || '0%'}`;
- }
-
- if (percent > 0) {
- this.updateProgress(percent, status);
- } else {
- this.updateStatus(status);
- }
- });
-
- this.socket.on('download_completed', (data) => {
- this.isDownloading = false;
- this.isPaused = false;
- this.hideDownloadQueue();
- this.hideStatus();
- this.showToast(this.localization.getText('download-completed'), 'success');
- this.loadSeries();
- this.clearSelection();
- });
-
- this.socket.on('download_error', (data) => {
- this.isDownloading = false;
- this.isPaused = false;
- this.hideDownloadQueue();
- this.hideStatus();
- this.showToast(`${this.localization.getText('download-failed')}: ${data.message}`, 'error');
- });
-
- // Download queue status events
- this.socket.on('download_queue_completed', () => {
- this.updateProcessStatus('download', false);
- this.showToast('All downloads completed!', 'success');
- });
-
- this.socket.on('download_stop_requested', () => {
- this.showToast('Stopping downloads...', 'info');
- });
-
- this.socket.on('download_stopped', () => {
- this.updateProcessStatus('download', false);
- this.showToast('Downloads stopped', 'success');
- });
-
- // Download queue events
- this.socket.on('download_queue_update', (data) => {
- this.updateDownloadQueue(data);
- });
-
- this.socket.on('download_episode_update', (data) => {
- this.updateCurrentEpisode(data);
- });
-
- this.socket.on('download_series_completed', (data) => {
- this.updateDownloadProgress(data);
- });
-
- // Download control events
- this.socket.on('download_paused', () => {
- this.isPaused = true;
- this.updateStatus(this.localization.getText('paused'));
- });
-
- this.socket.on('download_resumed', () => {
- this.isPaused = false;
- this.updateStatus(this.localization.getText('downloading'));
- });
-
- this.socket.on('download_cancelled', () => {
- this.isDownloading = false;
- this.isPaused = false;
- this.hideDownloadQueue();
- this.hideStatus();
- this.showToast('Download cancelled', 'warning');
- });
- }
-
- bindEvents() {
- // Theme toggle
- document.getElementById('theme-toggle').addEventListener('click', () => {
- this.toggleTheme();
- });
-
- // Search functionality
- const searchInput = document.getElementById('search-input');
- const searchBtn = document.getElementById('search-btn');
- const clearSearch = document.getElementById('clear-search');
-
- searchBtn.addEventListener('click', () => {
- this.performSearch();
- });
-
- searchInput.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- this.performSearch();
- }
- });
-
- clearSearch.addEventListener('click', () => {
- searchInput.value = '';
- this.hideSearchResults();
- });
-
- // Series management
- document.getElementById('select-all').addEventListener('click', () => {
- this.toggleSelectAll();
- });
-
- document.getElementById('download-selected').addEventListener('click', () => {
- this.downloadSelected();
- });
-
- // Rescan
- document.getElementById('rescan-btn').addEventListener('click', () => {
- this.rescanSeries();
- });
-
- // Click on rescan status indicator to reopen scan overlay
- const rescanStatus = document.getElementById('rescan-status');
- if (rescanStatus) {
- rescanStatus.addEventListener('click', (e) => {
- e.stopPropagation();
- console.log('Rescan status clicked');
- this.reopenScanOverlay();
- });
- }
-
- // Configuration modal
- document.getElementById('config-btn').addEventListener('click', () => {
- this.showConfigModal();
- });
-
- document.getElementById('close-config').addEventListener('click', () => {
- this.hideConfigModal();
- });
-
- document.querySelector('#config-modal .modal-overlay').addEventListener('click', () => {
- this.hideConfigModal();
- });
-
- document.addEventListener('keydown', (e) => {
- const modal = document.getElementById('config-modal');
- if (e.key === 'Escape' && modal && !modal.classList.contains('hidden')) {
- this.hideConfigModal();
- }
- });
-
- // Scheduler configuration
- document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
- this.toggleSchedulerTimeInput();
- });
-
- document.getElementById('save-scheduler-config').addEventListener('click', () => {
- this.saveSchedulerConfig();
- });
-
- document.getElementById('test-scheduled-rescan').addEventListener('click', () => {
- this.testScheduledRescan();
- });
-
- // Logging configuration
- document.getElementById('save-logging-config').addEventListener('click', () => {
- this.saveLoggingConfig();
- });
-
- document.getElementById('test-logging').addEventListener('click', () => {
- this.testLogging();
- });
-
- document.getElementById('refresh-log-files').addEventListener('click', () => {
- this.loadLogFiles();
- });
-
- document.getElementById('cleanup-logs').addEventListener('click', () => {
- this.cleanupLogs();
- });
-
- // Configuration management
- document.getElementById('create-config-backup').addEventListener('click', () => {
- this.createConfigBackup();
- });
-
- document.getElementById('view-config-backups').addEventListener('click', () => {
- this.viewConfigBackups();
- });
-
- document.getElementById('export-config').addEventListener('click', () => {
- this.exportConfig();
- });
-
- document.getElementById('validate-config').addEventListener('click', () => {
- this.validateConfig();
- });
-
- document.getElementById('reset-config').addEventListener('click', () => {
- this.resetConfig();
- });
-
- document.getElementById('save-advanced-config').addEventListener('click', () => {
- this.saveAdvancedConfig();
- });
-
- // Main configuration
- document.getElementById('save-main-config').addEventListener('click', () => {
- this.saveMainConfig();
- });
-
- document.getElementById('reset-main-config').addEventListener('click', () => {
- this.resetMainConfig();
- });
-
- document.getElementById('test-connection').addEventListener('click', () => {
- this.testConnection();
- });
-
- document.getElementById('browse-directory').addEventListener('click', () => {
- this.browseDirectory();
- });
-
- // Status panel
- document.getElementById('close-status').addEventListener('click', () => {
- this.hideStatus();
- });
-
- // Logout functionality
- document.getElementById('logout-btn').addEventListener('click', () => {
- this.logout();
- });
-
- // Series filtering and sorting
- document.getElementById('show-missing-only').addEventListener('click', () => {
- this.toggleMissingOnlyFilter();
- });
-
- document.getElementById('show-all-series').addEventListener('click', () => {
- if (this.showMissingOnly) {
- this.toggleMissingOnlyFilter();
- }
- });
-
- document.getElementById('sort-alphabetical').addEventListener('click', () => {
- this.toggleAlphabeticalSort();
- });
- }
-
- initTheme() {
- const savedTheme = localStorage.getItem('theme') || 'light';
- this.setTheme(savedTheme);
- }
-
- setTheme(theme) {
- document.documentElement.setAttribute('data-theme', theme);
- localStorage.setItem('theme', theme);
-
- const themeIcon = document.querySelector('#theme-toggle i');
- themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
- }
-
- toggleTheme() {
- const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
- const newTheme = currentTheme === 'light' ? 'dark' : 'light';
- this.setTheme(newTheme);
- }
-
- async loadSeries() {
- try {
- this.showLoading();
-
- const response = await this.makeAuthenticatedRequest('/api/anime');
-
- if (!response) {
- // makeAuthenticatedRequest returns null and handles redirect on auth failure
- return;
- }
-
- const data = await response.json();
-
- // Check if response has the expected format
- if (Array.isArray(data)) {
- // API returns array of AnimeSummary objects with full serie data
- this.seriesData = data.map(anime => {
- // Count total missing episodes from the episode dictionary
- const episodeDict = anime.missing_episodes || {};
- const totalMissing = Object.values(episodeDict).reduce(
- (sum, episodes) => sum + (Array.isArray(episodes) ? episodes.length : 0),
- 0
- );
-
- return {
- key: anime.key,
- name: anime.name,
- site: anime.site,
- folder: anime.folder,
- episodeDict: episodeDict,
- missing_episodes: totalMissing,
- has_missing: anime.has_missing || totalMissing > 0
- };
- });
- } else if (data.status === 'success') {
- // Legacy format support
- this.seriesData = data.series;
- } else {
- this.showToast(`Error loading series: ${data.message || 'Unknown error'}`, 'error');
- return;
- }
-
- this.applyFiltersAndSort();
- this.renderSeries();
- } catch (error) {
- console.error('Error loading series:', error);
- this.showToast('Failed to load series', 'error');
- } finally {
- this.hideLoading();
- }
- }
-
- async makeAuthenticatedRequest(url, options = {}) {
- // Get JWT token from localStorage
- const token = localStorage.getItem('access_token');
-
- // Check if token exists
- if (!token) {
- window.location.href = '/login';
- return null;
- }
-
- // Include Authorization header with Bearer token
- const requestOptions = {
- credentials: 'same-origin',
- ...options,
- headers: {
- 'Authorization': `Bearer ${token}`,
- ...options.headers
- }
- };
-
- const response = await fetch(url, requestOptions);
-
- if (response.status === 401) {
- // Token is invalid or expired, clear it and redirect to login
- localStorage.removeItem('access_token');
- localStorage.removeItem('token_expires_at');
- window.location.href = '/login';
- return null;
- }
-
- return response;
- }
-
- applyFiltersAndSort() {
- let filtered = [...this.seriesData];
-
- // Sort based on the current sorting mode
- filtered.sort((a, b) => {
- if (this.sortAlphabetical) {
- // Pure alphabetical sorting when A-Z is enabled
- return this.getDisplayName(a).localeCompare(this.getDisplayName(b));
- } else {
- // Default sorting: missing episodes first (descending), then by name
- // Always show series with missing episodes first
- if (a.missing_episodes > 0 && b.missing_episodes === 0) return -1;
- if (a.missing_episodes === 0 && b.missing_episodes > 0) return 1;
-
- // If both have missing episodes, sort by count (descending)
- if (a.missing_episodes > 0 && b.missing_episodes > 0) {
- if (a.missing_episodes !== b.missing_episodes) {
- return b.missing_episodes - a.missing_episodes;
- }
- }
-
- // For series with same missing episode status, maintain stable order
- return 0;
- }
- });
-
- // Apply missing episodes filter
- if (this.showMissingOnly) {
- filtered = filtered.filter(serie => serie.missing_episodes > 0);
- }
-
- this.filteredSeriesData = filtered;
- this.renderSeries();
- }
-
- renderSeries() {
- const grid = document.getElementById('series-grid');
- const dataToRender = this.filteredSeriesData.length > 0 ? this.filteredSeriesData :
- (this.seriesData.length > 0 ? this.seriesData : []);
-
- if (dataToRender.length === 0) {
- const message = this.showMissingOnly ?
- 'No series with missing episodes found.' :
- 'No series found. Try searching for anime or rescanning your directory.';
-
- grid.innerHTML = `
-
- `;
- return;
- }
-
- grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join('');
-
- // Bind checkbox events - uses 'key' as identifier
- grid.querySelectorAll('.series-checkbox').forEach(checkbox => {
- checkbox.addEventListener('change', (e) => {
- this.toggleSerieSelection(e.target.dataset.key, e.target.checked);
- });
- });
- }
-
- createSerieCard(serie) {
- // Use 'key' as the primary identifier for selection and data operations
- const isSelected = this.selectedSeries.has(serie.key);
- const hasMissingEpisodes = serie.missing_episodes > 0;
- const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes
-
- return `
-
-
-
-
-
- ${hasMissingEpisodes ? `${serie.missing_episodes} missing episodes` : 'Complete'}
-
-
${serie.site}
-
-
- `;
- }
-
- toggleSerieSelection(key, selected) {
- // Only allow selection of series with missing episodes
- // Use 'key' as the primary identifier for lookup and selection
- const serie = this.seriesData.find(s => s.key === key);
- if (!serie || serie.missing_episodes === 0) {
- // Uncheck the checkbox if it was checked for a complete series
- const checkbox = document.querySelector(`input[data-key="${key}"]`);
- if (checkbox) checkbox.checked = false;
- return;
- }
-
- if (selected) {
- this.selectedSeries.add(key);
- } else {
- this.selectedSeries.delete(key);
- }
-
- this.updateSelectionUI();
- }
-
- updateSelectionUI() {
- const downloadBtn = document.getElementById('download-selected');
- const selectAllBtn = document.getElementById('select-all');
-
- // Get series that can be selected (have missing episodes)
- // Use 'key' as the primary identifier for selection tracking
- const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
- const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
- const selectableKeys = selectableSeries.map(serie => serie.key);
-
- downloadBtn.disabled = this.selectedSeries.size === 0;
-
- const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
-
- if (this.selectedSeries.size === 0) {
- selectAllBtn.innerHTML = 'Select All';
- } else if (allSelectableSelected && selectableKeys.length > 0) {
- selectAllBtn.innerHTML = 'Deselect All';
- } else {
- selectAllBtn.innerHTML = 'Select All';
- }
-
- // Update card appearances using 'key' as identifier
- document.querySelectorAll('.series-card').forEach(card => {
- const key = card.dataset.key;
- const isSelected = this.selectedSeries.has(key);
- card.classList.toggle('selected', isSelected);
- });
- }
-
- toggleSelectAll() {
- // Get series that can be selected (have missing episodes)
- // Use 'key' as the primary identifier for selection
- const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
- const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
- const selectableKeys = selectableSeries.map(serie => serie.key);
-
- const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
-
- if (allSelectableSelected && this.selectedSeries.size > 0) {
- // Deselect all selectable series
- selectableKeys.forEach(key => this.selectedSeries.delete(key));
- document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false);
- } else {
- // Select all selectable series
- selectableKeys.forEach(key => this.selectedSeries.add(key));
- document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true);
- }
-
- this.updateSelectionUI();
- }
-
- clearSelection() {
- this.selectedSeries.clear();
- document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = false);
- this.updateSelectionUI();
- }
-
- async performSearch() {
- const searchInput = document.getElementById('search-input');
- const query = searchInput.value.trim();
-
- if (!query) {
- this.showToast('Please enter a search term', 'warning');
- return;
- }
-
- try {
- this.showLoading();
-
- const response = await this.makeAuthenticatedRequest('/api/anime/search', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ query })
- });
-
- if (!response) return;
- const data = await response.json();
-
- // Check if response is a direct array (new format) or wrapped object (legacy)
- if (Array.isArray(data)) {
- this.displaySearchResults(data);
- } else if (data.status === 'success') {
- this.displaySearchResults(data.results);
- } else {
- this.showToast(`Search error: ${data.message || 'Unknown error'}`, 'error');
- }
- } catch (error) {
- console.error('Search error:', error);
- this.showToast('Search failed', 'error');
- } finally {
- this.hideLoading();
- }
- }
-
- displaySearchResults(results) {
- const resultsContainer = document.getElementById('search-results');
- const resultsList = document.getElementById('search-results-list');
-
- if (results.length === 0) {
- resultsContainer.classList.add('hidden');
- this.showToast('No search results found', 'warning');
- return;
- }
-
- resultsList.innerHTML = results.map(result => `
-
- ${this.escapeHtml(this.getDisplayName(result))}
-
-
- `).join('');
-
- resultsContainer.classList.remove('hidden');
- }
-
- hideSearchResults() {
- document.getElementById('search-results').classList.add('hidden');
- }
-
- async addSeries(link, name) {
- try {
- const response = await this.makeAuthenticatedRequest('/api/anime/add', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ link, name })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.status === 'success') {
- this.showToast(data.message, 'success');
- this.loadSeries();
- this.hideSearchResults();
- document.getElementById('search-input').value = '';
- } else {
- this.showToast(`Error adding series: ${data.message}`, 'error');
- }
- } catch (error) {
- console.error('Error adding series:', error);
- this.showToast('Failed to add series', 'error');
- }
- }
-
- async downloadSelected() {
- console.log('=== downloadSelected v1.2 - Using key as primary identifier ===');
- if (this.selectedSeries.size === 0) {
- this.showToast('No series selected', 'warning');
- return;
- }
-
- try {
- // selectedSeries now contains 'key' values (not folder)
- const selectedKeys = Array.from(this.selectedSeries);
- console.log('=== Starting download for selected series ===');
- console.log('Selected keys:', selectedKeys);
- console.log('seriesData:', this.seriesData);
- let totalEpisodesAdded = 0;
- let failedSeries = [];
-
- // For each selected series, get its missing episodes and add to queue
- // Use 'key' to find the series in seriesData
- for (const key of selectedKeys) {
- const serie = this.seriesData.find(s => s.key === key);
- if (!serie || !serie.episodeDict) {
- console.error('Serie not found or has no episodeDict for key:', key, serie);
- failedSeries.push(key);
- continue;
- }
-
- // Validate required fields
- if (!serie.key) {
- console.error('Serie missing key:', serie);
- failedSeries.push(key);
- continue;
- }
-
- // Convert episodeDict format {season: [episodes]} to episode identifiers
- const episodes = [];
- for (const [season, episodeNumbers] of Object.entries(serie.episodeDict)) {
- if (Array.isArray(episodeNumbers)) {
- for (const episode of episodeNumbers) {
- episodes.push({
- season: parseInt(season),
- episode: episode
- });
- }
- }
- }
-
- if (episodes.length === 0) {
- console.log('No episodes to add for serie:', serie.name);
- continue;
- }
-
- // Use folder name as fallback if serie name is empty
- const serieName = serie.name && serie.name.trim() ? serie.name : serie.folder;
-
- // Add episodes to download queue
- const requestBody = {
- serie_id: serie.key,
- serie_folder: serie.folder,
- serie_name: serieName,
- episodes: episodes,
- priority: 'NORMAL'
- };
- console.log('Sending queue add request:', requestBody);
-
- const response = await this.makeAuthenticatedRequest('/api/queue/add', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody)
- });
-
- if (!response) {
- failedSeries.push(key);
- continue;
- }
-
- const data = await response.json();
- console.log('Queue add response:', response.status, data);
-
- // Log validation errors in detail
- if (data.detail && Array.isArray(data.detail)) {
- console.error('Validation errors:', JSON.stringify(data.detail, null, 2));
- }
-
- if (response.ok && data.status === 'success') {
- totalEpisodesAdded += episodes.length;
- } else {
- console.error('Failed to add to queue:', data);
- failedSeries.push(key);
- }
- }
-
- // Show result message
- console.log('=== Download request complete ===');
- console.log('Total episodes added:', totalEpisodesAdded);
- console.log('Failed series (keys):', failedSeries);
-
- if (totalEpisodesAdded > 0) {
- const message = failedSeries.length > 0
- ? `Added ${totalEpisodesAdded} episode(s) to queue (${failedSeries.length} series failed)`
- : `Added ${totalEpisodesAdded} episode(s) to download queue`;
- this.showToast(message, 'success');
- } else {
- const errorDetails = failedSeries.length > 0
- ? `Failed series (keys): ${failedSeries.join(', ')}`
- : 'No episodes were added. Check browser console for details.';
- console.error('Failed to add episodes. Details:', errorDetails);
- this.showToast('Failed to add episodes to queue. Check console for details.', 'error');
- }
- } catch (error) {
- console.error('Download error:', error);
- this.showToast('Failed to start download', 'error');
- }
- }
-
- async rescanSeries() {
- try {
- // Show the overlay immediately before making the API call
- this.showScanProgressOverlay({
- directory: 'Starting scan...',
- total_items: 0
- });
- this.updateProcessStatus('rescan', true);
-
- const response = await this.makeAuthenticatedRequest('/api/anime/rescan', {
- method: 'POST'
- });
-
- if (!response) {
- this.removeScanProgressOverlay();
- this.updateProcessStatus('rescan', false);
- return;
- }
- const data = await response.json();
-
- // Debug logging
- console.log('Rescan response:', data);
- console.log('Success value:', data.success, 'Type:', typeof data.success);
-
- // Note: The scan progress will be updated via WebSocket events
- // The overlay will be closed when scan_completed is received
- if (data.success !== true) {
- this.removeScanProgressOverlay();
- this.updateProcessStatus('rescan', false);
- this.showToast(`Rescan error: ${data.message}`, 'error');
- }
- } catch (error) {
- console.error('Rescan error:', error);
- this.removeScanProgressOverlay();
- this.updateProcessStatus('rescan', false);
- this.showToast('Failed to start rescan', 'error');
- }
- }
-
- showStatus(message, showProgress = false, showControls = false) {
- const panel = document.getElementById('status-panel');
- const messageEl = document.getElementById('status-message');
- const progressContainer = document.getElementById('progress-container');
- const controlsContainer = document.getElementById('download-controls');
-
- messageEl.textContent = message;
- progressContainer.classList.toggle('hidden', !showProgress);
- controlsContainer.classList.toggle('hidden', !showControls);
-
- if (showProgress) {
- this.updateProgress(0);
- }
-
- panel.classList.remove('hidden');
- }
-
- updateStatus(message) {
- document.getElementById('status-message').textContent = message;
- }
-
- updateProgress(percent, message = null) {
- const fill = document.getElementById('progress-fill');
- const text = document.getElementById('progress-text');
-
- fill.style.width = `${percent}%`;
- text.textContent = message || `${percent}%`;
- }
-
- hideStatus() {
- document.getElementById('status-panel').classList.add('hidden');
- }
-
- /**
- * Show the scan progress overlay with spinner and initial state
- * @param {Object} data - Scan started event data
- */
- showScanProgressOverlay(data) {
- // Remove existing overlay if present
- this.removeScanProgressOverlay();
-
- // Store total items for progress calculation
- this.scanTotalItems = data?.total_items || 0;
-
- // Store last scan data for reopening
- this._lastScanData = data;
-
- // Create overlay element
- const overlay = document.createElement('div');
- overlay.id = 'scan-progress-overlay';
- overlay.className = 'scan-progress-overlay';
-
- const totalDisplay = this.scanTotalItems > 0 ? this.scanTotalItems : '...';
-
- overlay.innerHTML = `
-
-
-
-
- 0 / ${totalDisplay} directories
-
-
-
- 0
- Scanned
-
-
- 0
- Series Found
-
-
-
- Current:
- ${this.escapeHtml(data?.directory || 'Initializing...')}
-
-
-
- 0.0s
-
-
- `;
-
- document.body.appendChild(overlay);
-
- // Add click-outside-to-close handler
- overlay.addEventListener('click', (e) => {
- // Only close if clicking the overlay background, not the container
- if (e.target === overlay) {
- this.removeScanProgressOverlay();
- }
- });
-
- // Trigger animation by adding visible class after a brief delay
- requestAnimationFrame(() => {
- overlay.classList.add('visible');
- });
- }
-
- /**
- * Update the scan progress overlay with current progress
- * @param {Object} data - Scan progress event data
- */
- updateScanProgressOverlay(data) {
- const overlay = document.getElementById('scan-progress-overlay');
- if (!overlay) return;
-
- // Update total items if provided (in case it wasn't available at start)
- if (data.total_items && data.total_items > 0) {
- this.scanTotalItems = data.total_items;
- const totalCount = document.getElementById('scan-total-count');
- if (totalCount) {
- totalCount.textContent = this.scanTotalItems;
- }
- }
-
- // Update progress bar
- const progressBar = document.getElementById('scan-progress-bar');
- if (progressBar && this.scanTotalItems > 0 && data.directories_scanned !== undefined) {
- const percentage = Math.min(100, (data.directories_scanned / this.scanTotalItems) * 100);
- progressBar.style.width = `${percentage}%`;
- }
-
- // Update current/total count display
- const currentCount = document.getElementById('scan-current-count');
- if (currentCount && data.directories_scanned !== undefined) {
- currentCount.textContent = data.directories_scanned;
- }
-
- // Update directories count
- const dirCount = document.getElementById('scan-directories-count');
- if (dirCount && data.directories_scanned !== undefined) {
- dirCount.textContent = data.directories_scanned;
- }
-
- // Update files/series count
- const filesCount = document.getElementById('scan-files-count');
- if (filesCount && data.files_found !== undefined) {
- filesCount.textContent = data.files_found;
- }
-
- // Update current directory (truncate if too long)
- const currentPath = document.getElementById('scan-current-path');
- if (currentPath && data.current_directory) {
- const maxLength = 50;
- let displayPath = data.current_directory;
- if (displayPath.length > maxLength) {
- displayPath = '...' + displayPath.slice(-maxLength + 3);
- }
- currentPath.textContent = displayPath;
- currentPath.title = data.current_directory; // Full path on hover
- }
- }
-
- /**
- * Hide the scan progress overlay with completion summary
- * @param {Object} data - Scan completed event data
- */
- hideScanProgressOverlay(data) {
- const overlay = document.getElementById('scan-progress-overlay');
- if (!overlay) return;
-
- const container = overlay.querySelector('.scan-progress-container');
- if (container) {
- container.classList.add('completed');
- }
-
- // Update title
- const titleText = overlay.querySelector('.scan-title-text');
- if (titleText) {
- titleText.textContent = 'Scan Complete';
- }
-
- // Complete the progress bar
- const progressBar = document.getElementById('scan-progress-bar');
- if (progressBar) {
- progressBar.style.width = '100%';
- }
-
- // Update final stats
- if (data) {
- const dirCount = document.getElementById('scan-directories-count');
- if (dirCount && data.total_directories !== undefined) {
- dirCount.textContent = data.total_directories;
- }
-
- const filesCount = document.getElementById('scan-files-count');
- if (filesCount && data.total_files !== undefined) {
- filesCount.textContent = data.total_files;
- }
-
- // Update progress text to show final count
- const currentCount = document.getElementById('scan-current-count');
- const totalCount = document.getElementById('scan-total-count');
- if (currentCount && data.total_directories !== undefined) {
- currentCount.textContent = data.total_directories;
- }
- if (totalCount && data.total_directories !== undefined) {
- totalCount.textContent = data.total_directories;
- }
-
- // Show elapsed time
- const elapsedTimeEl = document.getElementById('scan-elapsed-time');
- const elapsedValueEl = document.getElementById('scan-elapsed-value');
- if (elapsedTimeEl && elapsedValueEl && data.elapsed_seconds !== undefined) {
- elapsedValueEl.textContent = `${data.elapsed_seconds.toFixed(1)}s`;
- elapsedTimeEl.classList.remove('hidden');
- }
-
- // Update current directory to show completion message
- const currentPath = document.getElementById('scan-current-path');
- if (currentPath) {
- currentPath.textContent = 'Scan finished successfully';
- }
- }
-
- // Auto-dismiss after 3 seconds
- setTimeout(() => {
- this.removeScanProgressOverlay();
- }, 3000);
- }
-
- /**
- * Remove the scan progress overlay from the DOM
- */
- removeScanProgressOverlay() {
- const overlay = document.getElementById('scan-progress-overlay');
- if (overlay) {
- overlay.classList.remove('visible');
- // Wait for fade out animation before removing
- setTimeout(() => {
- if (overlay.parentElement) {
- overlay.remove();
- }
- }, 300);
- }
- }
-
- /**
- * Reopen the scan progress overlay if a scan is in progress
- * Called when user clicks on the rescan status indicator
- */
- async reopenScanOverlay() {
- // Check if overlay already exists
- const existingOverlay = document.getElementById('scan-progress-overlay');
- if (existingOverlay) {
- // Overlay is already open, do nothing
- return;
- }
-
- // Check if scan is running via API
- try {
- const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
- if (!response || !response.ok) {
- console.log('Could not fetch scan status');
- return;
- }
-
- const data = await response.json();
- console.log('Scan status for reopen:', data);
-
- if (data.is_scanning) {
- // A scan is in progress, show the overlay
- this.showScanProgressOverlay({
- directory: data.directory,
- total_items: data.total_items
- });
-
- // Update with current progress
- this.updateScanProgressOverlay({
- directories_scanned: data.directories_scanned,
- files_found: data.directories_scanned,
- current_directory: data.current_directory,
- total_items: data.total_items
- });
- }
- } catch (error) {
- console.error('Error checking scan status for reopen:', error);
- }
- }
-
- /**
- * Check if a scan is currently in progress (useful after page reload)
- * and show the progress overlay if so
- */
- async checkActiveScanStatus() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
- if (!response || !response.ok) {
- console.log('Could not fetch scan status, response:', response?.status);
- return;
- }
-
- const data = await response.json();
- console.log('Scan status check result:', data);
-
- if (data.is_scanning) {
- console.log('Scan is active, updating UI indicators');
-
- // Update the process status indicator FIRST before showing overlay
- // This ensures the header icon shows the running state immediately
- this.updateProcessStatus('rescan', true);
-
- // A scan is in progress, show the overlay
- this.showScanProgressOverlay({
- directory: data.directory,
- total_items: data.total_items
- });
-
- // Update with current progress
- this.updateScanProgressOverlay({
- directories_scanned: data.directories_scanned,
- files_found: data.directories_scanned,
- current_directory: data.current_directory,
- total_items: data.total_items
- });
-
- // Double-check the status indicator was updated
- const statusElement = document.getElementById('rescan-status');
- if (statusElement) {
- console.log('Rescan status element classes:', statusElement.className);
- } else {
- console.warn('Rescan status element not found in DOM');
- }
- } else {
- console.log('No active scan detected');
- // Ensure indicator shows idle state
- this.updateProcessStatus('rescan', false);
- }
- } catch (error) {
- console.error('Error checking scan status:', error);
- }
- }
-
- showLoading() {
- document.getElementById('loading-overlay').classList.remove('hidden');
- }
-
- hideLoading() {
- document.getElementById('loading-overlay').classList.add('hidden');
- }
-
- showToast(message, type = 'info') {
- const container = document.getElementById('toast-container');
- const toast = document.createElement('div');
-
- toast.className = `toast ${type}`;
- toast.innerHTML = `
-
- ${this.escapeHtml(message)}
-
-
- `;
-
- container.appendChild(toast);
-
- // Auto-remove after 5 seconds
- setTimeout(() => {
- if (toast.parentElement) {
- toast.remove();
- }
- }, 5000);
- }
-
- escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- /**
- * Get display name for anime/series object.
- * Returns name if available and not empty, otherwise returns key.
- * @param {Object} anime - Anime/series object with name and key properties
- * @returns {string} Display name
- */
- getDisplayName(anime) {
- if (!anime) return '';
- // Use name if it exists and is not empty (after trimming whitespace)
- const name = anime.name || '';
- const trimmedName = name.trim();
- if (trimmedName) {
- return trimmedName;
- }
- // Fallback to key
- return anime.key || anime.folder || '';
- }
-
- updateConnectionStatus() {
- const indicator = document.getElementById('connection-status-display');
- if (indicator) {
- const statusIndicator = indicator.querySelector('.status-indicator');
- const statusText = indicator.querySelector('.status-text');
-
- if (this.isConnected) {
- statusIndicator.classList.add('connected');
- statusText.textContent = this.localization.getText('connected');
- } else {
- statusIndicator.classList.remove('connected');
- statusText.textContent = this.localization.getText('disconnected');
- }
- }
- }
-
- updateProcessStatus(processName, isRunning, hasError = false) {
- const statusElement = document.getElementById(`${processName}-status`);
- if (!statusElement) {
- console.warn(`Process status element not found: ${processName}-status`);
- return;
- }
-
- const statusDot = statusElement.querySelector('.status-dot');
- if (!statusDot) {
- console.warn(`Status dot not found in ${processName}-status element`);
- return;
- }
-
- // Remove all status classes from both dot and element
- statusDot.classList.remove('idle', 'running', 'error');
- statusElement.classList.remove('running', 'error', 'idle');
-
- // Capitalize process name for display
- const displayName = processName.charAt(0).toUpperCase() + processName.slice(1);
-
- if (hasError) {
- statusDot.classList.add('error');
- statusElement.classList.add('error');
- statusElement.title = `${displayName} error - click for details`;
- } else if (isRunning) {
- statusDot.classList.add('running');
- statusElement.classList.add('running');
- statusElement.title = `${displayName} is running...`;
- } else {
- statusDot.classList.add('idle');
- statusElement.classList.add('idle');
- statusElement.title = `${displayName} is idle`;
- }
-
- console.log(`Process status updated: ${processName} = ${isRunning ? 'running' : (hasError ? 'error' : 'idle')}`);
- }
-
- async showConfigModal() {
- const modal = document.getElementById('config-modal');
-
- try {
- // Load current status
- const response = await this.makeAuthenticatedRequest('/api/anime/status');
- if (!response) return;
- const data = await response.json();
-
- document.getElementById('anime-directory-input').value = data.directory || '';
- document.getElementById('series-count-input').value = data.series_count || '0';
-
- // Load scheduler configuration
- await this.loadSchedulerConfig();
-
- // Load logging configuration
- await this.loadLoggingConfig();
-
- // Load advanced configuration
- await this.loadAdvancedConfig();
-
- modal.classList.remove('hidden');
- } catch (error) {
- console.error('Error loading configuration:', error);
- this.showToast('Failed to load configuration', 'error');
- }
- }
-
- hideConfigModal() {
- document.getElementById('config-modal').classList.add('hidden');
- }
-
- async loadSchedulerConfig() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- const config = data.config || {};
- const schedulerStatus = data.status || {};
-
- // Update UI elements
- document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
- document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
- document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
-
- // Update day-of-week checkboxes
- const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
- ['mon','tue','wed','thu','fri','sat','sun'].forEach(day => {
- const cb = document.getElementById(`scheduler-day-${day}`);
- if (cb) cb.checked = days.includes(day);
- });
-
- // Update status display
- const nextRunEl = document.getElementById('scheduler-next-run');
- if (nextRunEl) {
- nextRunEl.textContent = schedulerStatus.next_run
- ? new Date(schedulerStatus.next_run).toLocaleString()
- : 'Not scheduled';
- }
- const lastRunEl = document.getElementById('last-rescan-time');
- if (lastRunEl) {
- lastRunEl.textContent = schedulerStatus.last_run
- ? new Date(schedulerStatus.last_run).toLocaleString()
- : 'Never';
- }
-
- const statusBadge = document.getElementById('scheduler-running-status');
- if (statusBadge) {
- statusBadge.textContent = schedulerStatus.is_running ? 'Running' : 'Stopped';
- statusBadge.className = `info-value status-badge ${schedulerStatus.is_running ? 'running' : 'stopped'}`;
- }
-
- // Enable/disable time/day inputs based on checkbox
- this.toggleSchedulerTimeInput();
- }
- } catch (error) {
- console.error('Error loading scheduler config:', error);
- this.showToast('Failed to load scheduler configuration', 'error');
- }
- }
-
- async saveSchedulerConfig() {
- try {
- const enabled = document.getElementById('scheduled-rescan-enabled').checked;
- const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
- const autoDownload = document.getElementById('auto-download-after-rescan').checked;
-
- // Collect checked day-of-week values
- const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
- .filter(day => {
- const cb = document.getElementById(`scheduler-day-${day}`);
- return cb ? cb.checked : true;
- });
-
- const response = await this.makeAuthenticatedRequest('/api/scheduler/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- enabled: enabled,
- schedule_time: scheduleTime,
- schedule_days: scheduleDays,
- auto_download_after_rescan: autoDownload
- })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Scheduler configuration saved successfully', 'success');
- // Update next-run display from response
- const nextRunEl = document.getElementById('scheduler-next-run');
- if (nextRunEl && data.status && data.status.next_run) {
- nextRunEl.textContent = new Date(data.status.next_run).toLocaleString();
- }
- // Reload config to sync the full UI
- await this.loadSchedulerConfig();
- } else {
- this.showToast(`Failed to save config: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error saving scheduler config:', error);
- this.showToast('Failed to save scheduler configuration', 'error');
- }
- }
-
- async testScheduledRescan() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/scheduler/trigger-rescan', {
- method: 'POST'
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Test rescan triggered successfully', 'success');
- } else {
- this.showToast(`Failed to trigger test rescan: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error triggering test rescan:', error);
- this.showToast('Failed to trigger test rescan', 'error');
- }
- }
-
- toggleSchedulerTimeInput() {
- const enabled = document.getElementById('scheduled-rescan-enabled').checked;
- const timeConfig = document.getElementById('rescan-time-config');
- const daysConfig = document.getElementById('rescan-days-config');
- const nextRunEl = document.getElementById('scheduler-next-run');
-
- if (timeConfig) {
- timeConfig.classList.toggle('enabled', enabled);
- }
- if (daysConfig) {
- daysConfig.classList.toggle('enabled', enabled);
- }
- if (nextRunEl) {
- nextRunEl.parentElement && nextRunEl.parentElement.parentElement
- ? nextRunEl.parentElement.parentElement.classList.toggle('hidden', !enabled)
- : null;
- }
- }
-
- async loadLoggingConfig() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/logging/config');
- if (!response) return;
-
- const data = await response.json();
-
- if (data.success) {
- const config = data.config;
-
- // Set form values
- document.getElementById('log-level').value = config.log_level || 'INFO';
- document.getElementById('enable-console-logging').checked = config.enable_console_logging !== false;
- document.getElementById('enable-console-progress').checked = config.enable_console_progress === true;
- document.getElementById('enable-fail2ban-logging').checked = config.enable_fail2ban_logging !== false;
-
- // Load log files
- await this.loadLogFiles();
- }
- } catch (error) {
- console.error('Error loading logging config:', error);
- this.showToast('Failed to load logging configuration', 'error');
- }
- }
-
- async loadLogFiles() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/logging/files');
- if (!response) return;
-
- const data = await response.json();
-
- if (data.success) {
- const container = document.getElementById('log-files-list');
- container.innerHTML = '';
-
- if (data.files.length === 0) {
- container.innerHTML = 'No log files found
';
- return;
- }
-
- data.files.forEach(file => {
- const item = document.createElement('div');
- item.className = 'log-file-item';
-
- const info = document.createElement('div');
- info.className = 'log-file-info';
-
- const name = document.createElement('div');
- name.className = 'log-file-name';
- name.textContent = file.name;
-
- const details = document.createElement('div');
- details.className = 'log-file-details';
- details.textContent = `Size: ${file.size_mb} MB • Modified: ${new Date(file.modified).toLocaleDateString()}`;
-
- info.appendChild(name);
- info.appendChild(details);
-
- const actions = document.createElement('div');
- actions.className = 'log-file-actions';
-
- const downloadBtn = document.createElement('button');
- downloadBtn.className = 'btn btn-xs btn-secondary';
- downloadBtn.innerHTML = '';
- downloadBtn.title = 'Download';
- downloadBtn.onclick = () => this.downloadLogFile(file.name);
-
- const viewBtn = document.createElement('button');
- viewBtn.className = 'btn btn-xs btn-secondary';
- viewBtn.innerHTML = '';
- viewBtn.title = 'View Last 100 Lines';
- viewBtn.onclick = () => this.viewLogFile(file.name);
-
- actions.appendChild(downloadBtn);
- actions.appendChild(viewBtn);
-
- item.appendChild(info);
- item.appendChild(actions);
-
- container.appendChild(item);
- });
- }
- } catch (error) {
- console.error('Error loading log files:', error);
- this.showToast('Failed to load log files', 'error');
- }
- }
-
- async saveLoggingConfig() {
- try {
- const config = {
- log_level: document.getElementById('log-level').value,
- enable_console_logging: document.getElementById('enable-console-logging').checked,
- enable_console_progress: document.getElementById('enable-console-progress').checked,
- enable_fail2ban_logging: document.getElementById('enable-fail2ban-logging').checked
- };
-
- const response = await this.makeAuthenticatedRequest('/api/logging/config', {
- method: 'POST',
- body: JSON.stringify(config)
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Logging configuration saved successfully', 'success');
- await this.loadLoggingConfig();
- } else {
- this.showToast(`Failed to save logging config: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error saving logging config:', error);
- this.showToast('Failed to save logging configuration', 'error');
- }
- }
-
- async testLogging() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/logging/test', {
- method: 'POST'
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Test messages logged successfully', 'success');
- setTimeout(() => this.loadLogFiles(), 1000); // Refresh log files after a second
- } else {
- this.showToast(`Failed to test logging: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error testing logging:', error);
- this.showToast('Failed to test logging', 'error');
- }
- }
-
- async loadAdvancedConfig() {
- // Placeholder for advanced configuration loading
- // This method is called by showConfigModal but doesn't need to do anything special yet
- console.log('Advanced configuration loaded (placeholder)');
- }
-
- async cleanupLogs() {
- const days = prompt('Delete log files older than how many days?', '30');
- if (!days || isNaN(days) || days < 1) {
- this.showToast('Invalid number of days', 'error');
- return;
- }
-
- try {
- const response = await this.makeAuthenticatedRequest('/api/logging/cleanup', {
- method: 'POST',
- body: JSON.stringify({ days: parseInt(days) })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast(data.message, 'success');
- await this.loadLogFiles();
- } else {
- this.showToast(`Failed to cleanup logs: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error cleaning up logs:', error);
- this.showToast('Failed to cleanup logs', 'error');
- }
- }
-
- downloadLogFile(filename) {
- // Create download link
- const link = document.createElement('a');
- link.href = `/api/logging/files/${encodeURIComponent(filename)}/download`;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- }
-
- async viewLogFile(filename) {
- try {
- const response = await this.makeAuthenticatedRequest(`/api/logging/files/${encodeURIComponent(filename)}/tail?lines=100`);
- if (!response) return;
-
- const data = await response.json();
-
- if (data.success) {
- // Create modal to show log content
- const modal = document.createElement('div');
- modal.className = 'modal';
- modal.style.display = 'block';
-
- const modalContent = document.createElement('div');
- modalContent.className = 'modal-content';
- modalContent.style.maxWidth = '80%';
- modalContent.style.maxHeight = '80%';
-
- const header = document.createElement('div');
- header.innerHTML = `Log File: ${filename}
Showing last ${data.showing_lines} of ${data.total_lines} lines
`;
-
- const content = document.createElement('pre');
- content.style.maxHeight = '60vh';
- content.style.overflow = 'auto';
- content.style.backgroundColor = '#f5f5f5';
- content.style.padding = '10px';
- content.style.fontSize = '12px';
- content.textContent = data.lines.join('\n');
-
- const closeBtn = document.createElement('button');
- closeBtn.textContent = 'Close';
- closeBtn.className = 'btn btn-secondary';
- closeBtn.onclick = () => document.body.removeChild(modal);
-
- modalContent.appendChild(header);
- modalContent.appendChild(content);
- modalContent.appendChild(closeBtn);
- modal.appendChild(modalContent);
- document.body.appendChild(modal);
-
- // Close on background click
- modal.onclick = (e) => {
- if (e.target === modal) {
- document.body.removeChild(modal);
- }
- };
- } else {
- this.showToast(`Failed to view log file: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error viewing log file:', error);
- this.showToast('Failed to view log file', 'error');
- }
- }
-
- // Configuration Management Methods
- async loadAdvancedConfig() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/config/section/advanced');
- if (!response) return;
-
- const data = await response.json();
-
- if (data.success) {
- const config = data.config;
- document.getElementById('max-concurrent-downloads').value = config.max_concurrent_downloads || 3;
- document.getElementById('provider-timeout').value = config.provider_timeout || 30;
- document.getElementById('enable-debug-mode').checked = config.enable_debug_mode === true;
- }
- } catch (error) {
- console.error('Error loading advanced config:', error);
- }
- }
-
- async saveAdvancedConfig() {
- try {
- const config = {
- max_concurrent_downloads: parseInt(document.getElementById('max-concurrent-downloads').value),
- provider_timeout: parseInt(document.getElementById('provider-timeout').value),
- enable_debug_mode: document.getElementById('enable-debug-mode').checked
- };
-
- const response = await this.makeAuthenticatedRequest('/api/config/section/advanced', {
- method: 'POST',
- body: JSON.stringify(config)
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Advanced configuration saved successfully', 'success');
- } else {
- this.showToast(`Failed to save config: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error saving advanced config:', error);
- this.showToast('Failed to save advanced configuration', 'error');
- }
- }
-
- // Main Configuration Methods
- async saveMainConfig() {
- try {
- const animeDirectory = document.getElementById('anime-directory-input').value.trim();
-
- if (!animeDirectory) {
- this.showToast('Please enter an anime directory path', 'error');
- return;
- }
-
- const response = await this.makeAuthenticatedRequest('/api/config/directory', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- directory: animeDirectory
- })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Main configuration saved successfully', 'success');
- // Refresh the status to get updated series count
- await this.refreshStatus();
- } else {
- this.showToast(`Failed to save configuration: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error saving main config:', error);
- this.showToast('Failed to save main configuration', 'error');
- }
- }
-
- async resetMainConfig() {
- if (confirm('Are you sure you want to reset the main configuration? This will clear the anime directory.')) {
- document.getElementById('anime-directory-input').value = '';
- document.getElementById('series-count-input').value = '0';
- this.showToast('Main configuration reset', 'info');
- }
- }
-
- async testConnection() {
- try {
- this.showToast('Testing connection...', 'info');
-
- const response = await this.makeAuthenticatedRequest('/api/diagnostics/network');
- if (!response) return;
-
- const data = await response.json();
-
- if (data.status === 'success') {
- const networkStatus = data.data;
- const connectionDiv = document.getElementById('connection-status-display');
- const statusIndicator = connectionDiv.querySelector('.status-indicator');
- const statusText = connectionDiv.querySelector('.status-text');
-
- if (networkStatus.aniworld_reachable) {
- statusIndicator.className = 'status-indicator connected';
- statusText.textContent = 'Connected';
- this.showToast('Connection test successful', 'success');
- } else {
- statusIndicator.className = 'status-indicator disconnected';
- statusText.textContent = 'Disconnected';
- this.showToast('Connection test failed', 'error');
- }
- } else {
- this.showToast('Connection test failed', 'error');
- }
- } catch (error) {
- console.error('Error testing connection:', error);
- this.showToast('Connection test failed', 'error');
- }
- }
-
- async browseDirectory() {
- // This would typically open a native directory browser
- // For web applications, we'll show a prompt for manual entry
- const currentPath = document.getElementById('anime-directory-input').value;
- const newPath = prompt('Enter the anime directory path:', currentPath);
-
- if (newPath !== null && newPath.trim() !== '') {
- document.getElementById('anime-directory-input').value = newPath.trim();
- }
- }
-
- async refreshStatus() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/anime/status');
- if (!response) return;
- const data = await response.json();
-
- document.getElementById('anime-directory-input').value = data.directory || '';
- document.getElementById('series-count-input').value = data.series_count || '0';
- } catch (error) {
- console.error('Error refreshing status:', error);
- }
- }
-
- async createConfigBackup() {
- const backupName = prompt('Enter backup name (optional):');
-
- try {
- const response = await this.makeAuthenticatedRequest('/api/config/backup', {
- method: 'POST',
- body: JSON.stringify({ name: backupName || '' })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast(`Backup created: ${data.filename}`, 'success');
- } else {
- this.showToast(`Failed to create backup: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error creating backup:', error);
- this.showToast('Failed to create backup', 'error');
- }
- }
-
- async viewConfigBackups() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/config/backups');
- if (!response) return;
-
- const data = await response.json();
-
- if (data.success) {
- this.showBackupsModal(data.backups);
- } else {
- this.showToast(`Failed to load backups: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error loading backups:', error);
- this.showToast('Failed to load backups', 'error');
- }
- }
-
- async validateConfig() {
- try {
- const response = await this.makeAuthenticatedRequest('/api/config/validate', {
- method: 'POST',
- body: JSON.stringify({}) // Validate current config
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showValidationResults(data.validation);
- } else {
- this.showToast(`Validation failed: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error validating config:', error);
- this.showToast('Failed to validate configuration', 'error');
- }
- }
-
- showValidationResults(validation) {
- const container = document.getElementById('validation-results');
- container.innerHTML = '';
- container.classList.remove('hidden');
-
- if (validation.valid) {
- const success = document.createElement('div');
- success.className = 'validation-success';
- success.innerHTML = ' Configuration is valid!';
- container.appendChild(success);
- } else {
- const header = document.createElement('div');
- header.innerHTML = 'Validation Issues Found:';
- container.appendChild(header);
- }
-
- // Show errors
- validation.errors.forEach(error => {
- const errorDiv = document.createElement('div');
- errorDiv.className = 'validation-error';
- errorDiv.innerHTML = ` Error: ${error}`;
- container.appendChild(errorDiv);
- });
-
- // Show warnings
- validation.warnings.forEach(warning => {
- const warningDiv = document.createElement('div');
- warningDiv.className = 'validation-warning';
- warningDiv.innerHTML = ` Warning: ${warning}`;
- container.appendChild(warningDiv);
- });
- }
-
- async resetConfig() {
- if (!confirm('Are you sure you want to reset all configuration to defaults? This cannot be undone (except by restoring a backup).')) {
- return;
- }
-
- try {
- const response = await this.makeAuthenticatedRequest('/api/config/reset', {
- method: 'POST',
- body: JSON.stringify({ preserve_security: true })
- });
-
- if (!response) return;
- const data = await response.json();
-
- if (data.success) {
- this.showToast('Configuration reset to defaults', 'success');
- // Reload the config modal
- setTimeout(() => {
- this.hideConfigModal();
- this.showConfigModal();
- }, 1000);
- } else {
- this.showToast(`Failed to reset config: ${data.error}`, 'error');
- }
- } catch (error) {
- console.error('Error resetting config:', error);
- this.showToast('Failed to reset configuration', 'error');
- }
- }
-
- showDownloadQueue(data) {
- const queueSection = document.getElementById('download-queue-section');
- const queueProgress = document.getElementById('queue-progress');
-
- queueProgress.textContent = `0/${data.total_series} series`;
- this.updateDownloadQueue({
- queue: data.queue || [],
- current_downloading: null,
- stats: {
- completed_series: 0,
- total_series: data.total_series
- }
- });
-
- queueSection.classList.remove('hidden');
- }
-
- hideDownloadQueue() {
- const queueSection = document.getElementById('download-queue-section');
- const currentDownload = document.getElementById('current-download');
-
- queueSection.classList.add('hidden');
- currentDownload.classList.add('hidden');
- }
-
- updateDownloadQueue(data) {
- const queueList = document.getElementById('queue-list');
- const currentDownload = document.getElementById('current-download');
- const queueProgress = document.getElementById('queue-progress');
-
- // Update overall progress
- if (data.stats) {
- queueProgress.textContent = `${data.stats.completed_series}/${data.stats.total_series} series`;
- }
-
- // Update current downloading
- if (data.current_downloading) {
- currentDownload.classList.remove('hidden');
- document.getElementById('current-serie-name').textContent = this.getDisplayName(data.current_downloading);
- document.getElementById('current-episode').textContent = `${data.current_downloading.missing_episodes} episodes remaining`;
- } else {
- currentDownload.classList.add('hidden');
- }
-
- // Update queue list
- if (data.queue && data.queue.length > 0) {
- queueList.innerHTML = data.queue.map((serie, index) => `
-
-
${index + 1}
-
${this.escapeHtml(this.getDisplayName(serie))}
-
Waiting
-
- `).join('');
- } else {
- queueList.innerHTML = 'No series in queue
';
- }
- }
-
- updateCurrentEpisode(data) {
- const currentEpisode = document.getElementById('current-episode');
- const progressFill = document.getElementById('current-progress-fill');
- const progressText = document.getElementById('current-progress-text');
-
- if (currentEpisode && data.episode) {
- currentEpisode.textContent = `${data.episode} (${data.episode_progress})`;
- }
-
- // Update mini progress bar based on overall progress
- if (data.overall_progress && progressFill && progressText) {
- const [current, total] = data.overall_progress.split('/').map(n => parseInt(n));
- const percent = total > 0 ? (current / total * 100).toFixed(1) : 0;
-
- progressFill.style.width = `${percent}%`;
- progressText.textContent = `${percent}%`;
- }
- }
-
- updateDownloadProgress(data) {
- const queueProgress = document.getElementById('queue-progress');
-
- if (queueProgress && data.completed_series && data.total_series) {
- queueProgress.textContent = `${data.completed_series}/${data.total_series} series`;
- }
-
- this.showToast(`Completed: ${data.serie}`, 'success');
- }
-
- initMobileAndAccessibility() {
- // Initialize Mobile Responsive Manager
- if (typeof MobileResponsiveManager !== 'undefined') {
- this.mobileResponsive = new MobileResponsiveManager();
- }
-
- // Initialize Touch Gesture Manager
- if (typeof TouchGestureManager !== 'undefined') {
- this.touchGestures = new TouchGestureManager();
- }
-
- // Initialize Accessibility Manager
- if (typeof AccessibilityManager !== 'undefined') {
- this.accessibility = new AccessibilityManager();
- }
-
- // Initialize Screen Reader Manager
- if (typeof ScreenReaderManager !== 'undefined') {
- this.screenReader = new ScreenReaderManager();
- }
-
- // Initialize Color Contrast Manager
- if (typeof ColorContrastManager !== 'undefined') {
- this.colorContrast = new ColorContrastManager();
- }
-
- // Initialize Multi-Screen Manager
- if (typeof MultiScreenManager !== 'undefined') {
- this.multiScreen = new MultiScreenManager();
- }
-
- console.log('Mobile & Accessibility features initialized');
- }
-
- formatETA(seconds) {
- if (!seconds || seconds <= 0) return '---';
-
- if (seconds < 60) {
- return `${Math.round(seconds)}s`;
- } else if (seconds < 3600) {
- const minutes = Math.round(seconds / 60);
- return `${minutes}m`;
- } else if (seconds < 86400) {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.round((seconds % 3600) / 60);
- return `${hours}h ${minutes}m`;
- } else {
- const days = Math.floor(seconds / 86400);
- const hours = Math.round((seconds % 86400) / 3600);
- return `${days}d ${hours}h`;
- }
- }
-}
-
-// Initialize the application when DOM is loaded
-document.addEventListener('DOMContentLoaded', () => {
- window.app = new AniWorldApp();
-});
-
-// Global functions for inline event handlers
+/**
+ * AniWorld Manager - Main JavaScript Application
+ * Implements Fluent UI design principles with modern web app functionality
+ */
+
+class AniWorldApp {
+ constructor() {
+ this.socket = null;
+ this.selectedSeries = new Set(); // Uses 'key' as identifier
+ this.seriesData = []; // Series objects with 'key' as primary identifier
+ this.filteredSeriesData = [];
+ this.isConnected = false;
+ this.isDownloading = false;
+ this.isPaused = false;
+ this.localization = new Localization();
+ this.showMissingOnly = false;
+ this.sortAlphabetical = false;
+
+ this.init();
+ }
+
+ async init() {
+ await this.checkAuthentication();
+ this.initSocket();
+ this.bindEvents();
+ this.loadSeries();
+ this.initTheme();
+ this.updateConnectionStatus();
+ // Check scan status on page load (in case socket connect event is delayed)
+ this.checkActiveScanStatus();
+ }
+
+ async checkAuthentication() {
+ // Don't check authentication if we're already on login or setup pages
+ const currentPath = window.location.pathname;
+ if (currentPath === '/login' || currentPath === '/setup') {
+ return;
+ }
+
+ try {
+ // First check if we have a token
+ const token = localStorage.getItem('access_token');
+ console.log('checkAuthentication: token exists =', !!token);
+
+ if (!token) {
+ console.log('checkAuthentication: No token found, redirecting to /login');
+ window.location.href = '/login';
+ return;
+ }
+
+ // Build request with token
+ const headers = {
+ 'Authorization': `Bearer ${token}`
+ };
+
+ const response = await fetch('/api/auth/status', { headers });
+ console.log('checkAuthentication: response status =', response.status);
+
+ if (!response.ok) {
+ console.log('checkAuthentication: Response not OK, status =', response.status);
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ console.log('checkAuthentication: data =', data);
+
+ if (!data.configured) {
+ // No master password set, redirect to setup
+ console.log('checkAuthentication: Not configured, redirecting to /setup');
+ window.location.href = '/setup';
+ return;
+ }
+
+ if (!data.authenticated) {
+ // Not authenticated, redirect to login
+ console.log('checkAuthentication: Not authenticated, redirecting to /login');
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('token_expires_at');
+ window.location.href = '/login';
+ return;
+ }
+
+ // User is authenticated, show logout button
+ console.log('checkAuthentication: Authenticated successfully');
+ const logoutBtn = document.getElementById('logout-btn');
+ if (logoutBtn) {
+ logoutBtn.style.display = 'block';
+ }
+ } catch (error) {
+ console.error('Authentication check failed:', error);
+ // On error, clear token and redirect to login
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('token_expires_at');
+ window.location.href = '/login';
+ }
+ }
+
+ async logout() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/auth/logout', { method: 'POST' });
+
+ // Clear tokens from localStorage
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('token_expires_at');
+
+ if (response && response.ok) {
+ const data = await response.json();
+ if (data.status === 'ok') {
+ this.showToast('Logged out successfully', 'success');
+ } else {
+ this.showToast('Logged out', 'success');
+ }
+ } else {
+ // Even if the API fails, we cleared the token locally
+ this.showToast('Logged out', 'success');
+ }
+
+ setTimeout(() => {
+ window.location.href = '/login';
+ }, 1000);
+ } catch (error) {
+ console.error('Logout error:', error);
+ // Clear token even on error
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('token_expires_at');
+ this.showToast('Logged out', 'success');
+ setTimeout(() => {
+ window.location.href = '/login';
+ }, 1000);
+ }
+ }
+
+ toggleMissingOnlyFilter() {
+ this.showMissingOnly = !this.showMissingOnly;
+ const button = document.getElementById('show-missing-only');
+
+ button.setAttribute('data-active', this.showMissingOnly);
+ button.classList.toggle('active', this.showMissingOnly);
+
+ const icon = button.querySelector('i');
+ const text = button.querySelector('span');
+
+ if (this.showMissingOnly) {
+ icon.className = 'fas fa-filter-circle-xmark';
+ text.textContent = 'Show All Series';
+ } else {
+ icon.className = 'fas fa-filter';
+ text.textContent = 'Missing Episodes Only';
+ }
+
+ this.applyFiltersAndSort();
+ this.renderSeries();
+ this.clearSelection(); // Clear selection when filter changes
+ }
+
+ toggleAlphabeticalSort() {
+ this.sortAlphabetical = !this.sortAlphabetical;
+ const button = document.getElementById('sort-alphabetical');
+
+ button.setAttribute('data-active', this.sortAlphabetical);
+ button.classList.toggle('active', this.sortAlphabetical);
+
+ const icon = button.querySelector('i');
+ const text = button.querySelector('span');
+
+ if (this.sortAlphabetical) {
+ icon.className = 'fas fa-sort-alpha-up';
+ text.textContent = 'Default Sort';
+ } else {
+ icon.className = 'fas fa-sort-alpha-down';
+ text.textContent = 'A-Z Sort';
+ }
+
+ this.applyFiltersAndSort();
+ this.renderSeries();
+ }
+
+ initSocket() {
+ this.socket = io();
+
+ // Handle initial connection message from server
+ this.socket.on('connected', (data) => {
+ console.log('WebSocket connection confirmed', data);
+ });
+
+ this.socket.on('connect', () => {
+ this.isConnected = true;
+ console.log('Connected to server');
+
+ // Subscribe to rooms for targeted updates
+ // Valid rooms: downloads, queue, scan, system, errors
+ this.socket.join('scan');
+ this.socket.join('downloads');
+ this.socket.join('queue');
+
+ this.showToast(this.localization.getText('connected-server'), 'success');
+ this.updateConnectionStatus();
+
+ // Check if a scan is currently in progress (e.g., after page reload)
+ this.checkActiveScanStatus();
+ });
+
+ this.socket.on('disconnect', () => {
+ this.isConnected = false;
+ console.log('Disconnected from server');
+ this.showToast(this.localization.getText('disconnected-server'), 'warning');
+ this.updateConnectionStatus();
+ });
+
+ // Scan events - handle new detailed scan progress overlay
+ this.socket.on('scan_started', (data) => {
+ console.log('Scan started:', data);
+ this.showScanProgressOverlay(data);
+ this.updateProcessStatus('rescan', true);
+ });
+
+ this.socket.on('scan_progress', (data) => {
+ console.log('Scan progress:', data);
+ this.updateScanProgressOverlay(data);
+ });
+
+ // Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend)
+ const handleScanComplete = (data) => {
+ console.log('Scan completed:', data);
+ this.hideScanProgressOverlay(data);
+ this.showToast('Scan completed successfully', 'success');
+ this.updateProcessStatus('rescan', false);
+ this.loadSeries();
+ };
+ this.socket.on('scan_completed', handleScanComplete);
+ this.socket.on('scan_complete', handleScanComplete);
+
+ // Handle both 'scan_error' (legacy) and 'scan_failed' (new backend)
+ const handleScanError = (data) => {
+ this.hideStatus();
+ this.showToast(`Scan error: ${data.message || data.error}`, 'error');
+ this.updateProcessStatus('rescan', false, true);
+ };
+ this.socket.on('scan_error', handleScanError);
+ this.socket.on('scan_failed', handleScanError);
+
+ // Scheduled scan events
+ this.socket.on('scheduled_rescan_started', () => {
+ this.showToast('Scheduled rescan started', 'info');
+ this.updateProcessStatus('rescan', true);
+ });
+
+ this.socket.on('scheduled_rescan_completed', (data) => {
+ this.showToast('Scheduled rescan completed successfully', 'success');
+ this.updateProcessStatus('rescan', false);
+ this.loadSeries();
+ });
+
+ this.socket.on('scheduled_rescan_error', (data) => {
+ this.showToast(`Scheduled rescan error: ${data.error}`, 'error');
+ this.updateProcessStatus('rescan', false, true);
+ });
+
+ this.socket.on('scheduled_rescan_skipped', (data) => {
+ this.showToast(`Scheduled rescan skipped: ${data.reason}`, 'warning');
+ });
+
+ this.socket.on('auto_download_started', (data) => {
+ this.showToast('Auto-download started after scheduled rescan', 'info');
+ this.updateProcessStatus('download', true);
+ });
+
+ this.socket.on('auto_download_error', (data) => {
+ this.showToast(`Auto-download error: ${data.error}`, 'error');
+ this.updateProcessStatus('download', false, true);
+ });
+
+ // Download events
+ this.socket.on('download_started', (data) => {
+ this.isDownloading = true;
+ this.isPaused = false;
+ this.updateProcessStatus('download', true);
+ this.showDownloadQueue(data);
+ this.showStatus(`Starting download of ${data.total_series} series...`, true, true);
+ });
+
+ this.socket.on('download_progress', (data) => {
+ let status = '';
+ let percent = 0;
+
+ if (data.progress !== undefined) {
+ percent = data.progress;
+ status = `Downloading: ${percent.toFixed(1)}%`;
+
+ // Add speed information if available
+ if (data.speed_mbps && data.speed_mbps > 0) {
+ status += ` (${data.speed_mbps.toFixed(1)} Mbps)`;
+ }
+
+ // Add ETA information if available
+ if (data.eta_seconds && data.eta_seconds > 0) {
+ const eta = this.formatETA(data.eta_seconds);
+ status += ` - ETA: ${eta}`;
+ }
+ } else if (data.total_bytes) {
+ percent = ((data.downloaded_bytes || 0) / data.total_bytes * 100);
+ status = `Downloading: ${percent.toFixed(1)}%`;
+ } else if (data.downloaded_mb !== undefined) {
+ status = `Downloaded: ${data.downloaded_mb.toFixed(1)} MB`;
+ } else {
+ status = `Downloading: ${data.percent || '0%'}`;
+ }
+
+ if (percent > 0) {
+ this.updateProgress(percent, status);
+ } else {
+ this.updateStatus(status);
+ }
+ });
+
+ this.socket.on('download_completed', (data) => {
+ this.isDownloading = false;
+ this.isPaused = false;
+ this.hideDownloadQueue();
+ this.hideStatus();
+ this.showToast(this.localization.getText('download-completed'), 'success');
+ this.loadSeries();
+ this.clearSelection();
+ });
+
+ this.socket.on('download_error', (data) => {
+ this.isDownloading = false;
+ this.isPaused = false;
+ this.hideDownloadQueue();
+ this.hideStatus();
+ this.showToast(`${this.localization.getText('download-failed')}: ${data.message}`, 'error');
+ });
+
+ // Download queue status events
+ this.socket.on('download_queue_completed', () => {
+ this.updateProcessStatus('download', false);
+ this.showToast('All downloads completed!', 'success');
+ });
+
+ this.socket.on('download_stop_requested', () => {
+ this.showToast('Stopping downloads...', 'info');
+ });
+
+ this.socket.on('download_stopped', () => {
+ this.updateProcessStatus('download', false);
+ this.showToast('Downloads stopped', 'success');
+ });
+
+ // Download queue events
+ this.socket.on('download_queue_update', (data) => {
+ this.updateDownloadQueue(data);
+ });
+
+ this.socket.on('download_episode_update', (data) => {
+ this.updateCurrentEpisode(data);
+ });
+
+ this.socket.on('download_series_completed', (data) => {
+ this.updateDownloadProgress(data);
+ });
+
+ // Download control events
+ this.socket.on('download_paused', () => {
+ this.isPaused = true;
+ this.updateStatus(this.localization.getText('paused'));
+ });
+
+ this.socket.on('download_resumed', () => {
+ this.isPaused = false;
+ this.updateStatus(this.localization.getText('downloading'));
+ });
+
+ this.socket.on('download_cancelled', () => {
+ this.isDownloading = false;
+ this.isPaused = false;
+ this.hideDownloadQueue();
+ this.hideStatus();
+ this.showToast('Download cancelled', 'warning');
+ });
+ }
+
+ bindEvents() {
+ // Theme toggle
+ document.getElementById('theme-toggle').addEventListener('click', () => {
+ this.toggleTheme();
+ });
+
+ // Search functionality
+ const searchInput = document.getElementById('search-input');
+ const searchBtn = document.getElementById('search-btn');
+ const clearSearch = document.getElementById('clear-search');
+
+ searchBtn.addEventListener('click', () => {
+ this.performSearch();
+ });
+
+ searchInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.performSearch();
+ }
+ });
+
+ clearSearch.addEventListener('click', () => {
+ searchInput.value = '';
+ this.hideSearchResults();
+ });
+
+ // Series management
+ document.getElementById('select-all').addEventListener('click', () => {
+ this.toggleSelectAll();
+ });
+
+ document.getElementById('download-selected').addEventListener('click', () => {
+ this.downloadSelected();
+ });
+
+ // Rescan
+ document.getElementById('rescan-btn').addEventListener('click', () => {
+ this.rescanSeries();
+ });
+
+ // Click on rescan status indicator to reopen scan overlay
+ const rescanStatus = document.getElementById('rescan-status');
+ if (rescanStatus) {
+ rescanStatus.addEventListener('click', (e) => {
+ e.stopPropagation();
+ console.log('Rescan status clicked');
+ this.reopenScanOverlay();
+ });
+ }
+
+ // Configuration modal
+ document.getElementById('config-btn').addEventListener('click', () => {
+ this.showConfigModal();
+ });
+
+ document.getElementById('close-config').addEventListener('click', () => {
+ this.hideConfigModal();
+ });
+
+ document.querySelector('#config-modal .modal-overlay').addEventListener('click', () => {
+ this.hideConfigModal();
+ });
+
+ document.addEventListener('keydown', (e) => {
+ const modal = document.getElementById('config-modal');
+ if (e.key === 'Escape' && modal && !modal.classList.contains('hidden')) {
+ this.hideConfigModal();
+ }
+ });
+
+ // Scheduler configuration
+ document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
+ this.toggleSchedulerTimeInput();
+ });
+
+ document.getElementById('save-scheduler-config').addEventListener('click', () => {
+ this.saveSchedulerConfig();
+ });
+
+ document.getElementById('test-scheduled-rescan').addEventListener('click', () => {
+ this.testScheduledRescan();
+ });
+
+ // Logging configuration
+ document.getElementById('save-logging-config').addEventListener('click', () => {
+ this.saveLoggingConfig();
+ });
+
+ document.getElementById('test-logging').addEventListener('click', () => {
+ this.testLogging();
+ });
+
+ document.getElementById('refresh-log-files').addEventListener('click', () => {
+ this.loadLogFiles();
+ });
+
+ document.getElementById('cleanup-logs').addEventListener('click', () => {
+ this.cleanupLogs();
+ });
+
+ // Configuration management
+ document.getElementById('create-config-backup').addEventListener('click', () => {
+ this.createConfigBackup();
+ });
+
+ document.getElementById('view-config-backups').addEventListener('click', () => {
+ this.viewConfigBackups();
+ });
+
+ document.getElementById('export-config').addEventListener('click', () => {
+ this.exportConfig();
+ });
+
+ document.getElementById('validate-config').addEventListener('click', () => {
+ this.validateConfig();
+ });
+
+ document.getElementById('reset-config').addEventListener('click', () => {
+ this.resetConfig();
+ });
+
+ document.getElementById('save-advanced-config').addEventListener('click', () => {
+ this.saveAdvancedConfig();
+ });
+
+ // Main configuration
+ document.getElementById('save-main-config').addEventListener('click', () => {
+ this.saveMainConfig();
+ });
+
+ document.getElementById('reset-main-config').addEventListener('click', () => {
+ this.resetMainConfig();
+ });
+
+ document.getElementById('test-connection').addEventListener('click', () => {
+ this.testConnection();
+ });
+
+ document.getElementById('browse-directory').addEventListener('click', () => {
+ this.browseDirectory();
+ });
+
+ // Status panel
+ document.getElementById('close-status').addEventListener('click', () => {
+ this.hideStatus();
+ });
+
+ // Logout functionality
+ document.getElementById('logout-btn').addEventListener('click', () => {
+ this.logout();
+ });
+
+ // Series filtering and sorting
+ document.getElementById('show-missing-only').addEventListener('click', () => {
+ this.toggleMissingOnlyFilter();
+ });
+
+ document.getElementById('show-all-series').addEventListener('click', () => {
+ if (this.showMissingOnly) {
+ this.toggleMissingOnlyFilter();
+ }
+ });
+
+ document.getElementById('sort-alphabetical').addEventListener('click', () => {
+ this.toggleAlphabeticalSort();
+ });
+ }
+
+ initTheme() {
+ const savedTheme = localStorage.getItem('theme') || 'light';
+ this.setTheme(savedTheme);
+ }
+
+ setTheme(theme) {
+ document.documentElement.setAttribute('data-theme', theme);
+ localStorage.setItem('theme', theme);
+
+ const themeIcon = document.querySelector('#theme-toggle i');
+ themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
+ }
+
+ toggleTheme() {
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+ this.setTheme(newTheme);
+ }
+
+ async loadSeries() {
+ try {
+ this.showLoading();
+
+ const response = await this.makeAuthenticatedRequest('/api/anime');
+
+ if (!response) {
+ // makeAuthenticatedRequest returns null and handles redirect on auth failure
+ return;
+ }
+
+ const data = await response.json();
+
+ // Check if response has the expected format
+ if (Array.isArray(data)) {
+ // API returns array of AnimeSummary objects with full serie data
+ this.seriesData = data.map(anime => {
+ // Count total missing episodes from the episode dictionary
+ const episodeDict = anime.missing_episodes || {};
+ const totalMissing = Object.values(episodeDict).reduce(
+ (sum, episodes) => sum + (Array.isArray(episodes) ? episodes.length : 0),
+ 0
+ );
+
+ return {
+ key: anime.key,
+ name: anime.name,
+ site: anime.site,
+ folder: anime.folder,
+ episodeDict: episodeDict,
+ missing_episodes: totalMissing,
+ has_missing: anime.has_missing || totalMissing > 0
+ };
+ });
+ } else if (data.status === 'success') {
+ // Legacy format support
+ this.seriesData = data.series;
+ } else {
+ this.showToast(`Error loading series: ${data.message || 'Unknown error'}`, 'error');
+ return;
+ }
+
+ this.applyFiltersAndSort();
+ this.renderSeries();
+ } catch (error) {
+ console.error('Error loading series:', error);
+ this.showToast('Failed to load series', 'error');
+ } finally {
+ this.hideLoading();
+ }
+ }
+
+ async makeAuthenticatedRequest(url, options = {}) {
+ // Get JWT token from localStorage
+ const token = localStorage.getItem('access_token');
+
+ // Check if token exists
+ if (!token) {
+ window.location.href = '/login';
+ return null;
+ }
+
+ // Include Authorization header with Bearer token
+ const requestOptions = {
+ credentials: 'same-origin',
+ ...options,
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ ...options.headers
+ }
+ };
+
+ const response = await fetch(url, requestOptions);
+
+ if (response.status === 401) {
+ // Token is invalid or expired, clear it and redirect to login
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('token_expires_at');
+ window.location.href = '/login';
+ return null;
+ }
+
+ return response;
+ }
+
+ applyFiltersAndSort() {
+ let filtered = [...this.seriesData];
+
+ // Sort based on the current sorting mode
+ filtered.sort((a, b) => {
+ if (this.sortAlphabetical) {
+ // Pure alphabetical sorting when A-Z is enabled
+ return this.getDisplayName(a).localeCompare(this.getDisplayName(b));
+ } else {
+ // Default sorting: missing episodes first (descending), then by name
+ // Always show series with missing episodes first
+ if (a.missing_episodes > 0 && b.missing_episodes === 0) return -1;
+ if (a.missing_episodes === 0 && b.missing_episodes > 0) return 1;
+
+ // If both have missing episodes, sort by count (descending)
+ if (a.missing_episodes > 0 && b.missing_episodes > 0) {
+ if (a.missing_episodes !== b.missing_episodes) {
+ return b.missing_episodes - a.missing_episodes;
+ }
+ }
+
+ // For series with same missing episode status, maintain stable order
+ return 0;
+ }
+ });
+
+ // Apply missing episodes filter
+ if (this.showMissingOnly) {
+ filtered = filtered.filter(serie => serie.missing_episodes > 0);
+ }
+
+ this.filteredSeriesData = filtered;
+ this.renderSeries();
+ }
+
+ renderSeries() {
+ const grid = document.getElementById('series-grid');
+ const dataToRender = this.filteredSeriesData.length > 0 ? this.filteredSeriesData :
+ (this.seriesData.length > 0 ? this.seriesData : []);
+
+ if (dataToRender.length === 0) {
+ const message = this.showMissingOnly ?
+ 'No series with missing episodes found.' :
+ 'No series found. Try searching for anime or rescanning your directory.';
+
+ grid.innerHTML = `
+
+ `;
+ return;
+ }
+
+ grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join('');
+
+ // Bind checkbox events - uses 'key' as identifier
+ grid.querySelectorAll('.series-checkbox').forEach(checkbox => {
+ checkbox.addEventListener('change', (e) => {
+ this.toggleSerieSelection(e.target.dataset.key, e.target.checked);
+ });
+ });
+ }
+
+ createSerieCard(serie) {
+ // Use 'key' as the primary identifier for selection and data operations
+ const isSelected = this.selectedSeries.has(serie.key);
+ const hasMissingEpisodes = serie.missing_episodes > 0;
+ const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes
+
+ return `
+
+
+
+
+
+ ${hasMissingEpisodes ? `${serie.missing_episodes} missing episodes` : 'Complete'}
+
+
${serie.site}
+
+
+ `;
+ }
+
+ toggleSerieSelection(key, selected) {
+ // Only allow selection of series with missing episodes
+ // Use 'key' as the primary identifier for lookup and selection
+ const serie = this.seriesData.find(s => s.key === key);
+ if (!serie || serie.missing_episodes === 0) {
+ // Uncheck the checkbox if it was checked for a complete series
+ const checkbox = document.querySelector(`input[data-key="${key}"]`);
+ if (checkbox) checkbox.checked = false;
+ return;
+ }
+
+ if (selected) {
+ this.selectedSeries.add(key);
+ } else {
+ this.selectedSeries.delete(key);
+ }
+
+ this.updateSelectionUI();
+ }
+
+ updateSelectionUI() {
+ const downloadBtn = document.getElementById('download-selected');
+ const selectAllBtn = document.getElementById('select-all');
+
+ // Get series that can be selected (have missing episodes)
+ // Use 'key' as the primary identifier for selection tracking
+ const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
+ const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
+ const selectableKeys = selectableSeries.map(serie => serie.key);
+
+ downloadBtn.disabled = this.selectedSeries.size === 0;
+
+ const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
+
+ if (this.selectedSeries.size === 0) {
+ selectAllBtn.innerHTML = 'Select All';
+ } else if (allSelectableSelected && selectableKeys.length > 0) {
+ selectAllBtn.innerHTML = 'Deselect All';
+ } else {
+ selectAllBtn.innerHTML = 'Select All';
+ }
+
+ // Update card appearances using 'key' as identifier
+ document.querySelectorAll('.series-card').forEach(card => {
+ const key = card.dataset.key;
+ const isSelected = this.selectedSeries.has(key);
+ card.classList.toggle('selected', isSelected);
+ });
+ }
+
+ toggleSelectAll() {
+ // Get series that can be selected (have missing episodes)
+ // Use 'key' as the primary identifier for selection
+ const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
+ const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
+ const selectableKeys = selectableSeries.map(serie => serie.key);
+
+ const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
+
+ if (allSelectableSelected && this.selectedSeries.size > 0) {
+ // Deselect all selectable series
+ selectableKeys.forEach(key => this.selectedSeries.delete(key));
+ document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false);
+ } else {
+ // Select all selectable series
+ selectableKeys.forEach(key => this.selectedSeries.add(key));
+ document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true);
+ }
+
+ this.updateSelectionUI();
+ }
+
+ clearSelection() {
+ this.selectedSeries.clear();
+ document.querySelectorAll('.series-checkbox').forEach(cb => cb.checked = false);
+ this.updateSelectionUI();
+ }
+
+ async performSearch() {
+ const searchInput = document.getElementById('search-input');
+ const query = searchInput.value.trim();
+
+ if (!query) {
+ this.showToast('Please enter a search term', 'warning');
+ return;
+ }
+
+ try {
+ this.showLoading();
+
+ const response = await this.makeAuthenticatedRequest('/api/anime/search', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ // Check if response is a direct array (new format) or wrapped object (legacy)
+ if (Array.isArray(data)) {
+ this.displaySearchResults(data);
+ } else if (data.status === 'success') {
+ this.displaySearchResults(data.results);
+ } else {
+ this.showToast(`Search error: ${data.message || 'Unknown error'}`, 'error');
+ }
+ } catch (error) {
+ console.error('Search error:', error);
+ this.showToast('Search failed', 'error');
+ } finally {
+ this.hideLoading();
+ }
+ }
+
+ displaySearchResults(results) {
+ const resultsContainer = document.getElementById('search-results');
+ const resultsList = document.getElementById('search-results-list');
+
+ if (results.length === 0) {
+ resultsContainer.classList.add('hidden');
+ this.showToast('No search results found', 'warning');
+ return;
+ }
+
+ resultsList.innerHTML = results.map(result => `
+
+ ${this.escapeHtml(this.getDisplayName(result))}
+
+
+ `).join('');
+
+ resultsContainer.classList.remove('hidden');
+ }
+
+ hideSearchResults() {
+ document.getElementById('search-results').classList.add('hidden');
+ }
+
+ async addSeries(link, name) {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/anime/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ link, name })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ this.showToast(data.message, 'success');
+ this.loadSeries();
+ this.hideSearchResults();
+ document.getElementById('search-input').value = '';
+ } else {
+ this.showToast(`Error adding series: ${data.message}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error adding series:', error);
+ this.showToast('Failed to add series', 'error');
+ }
+ }
+
+ async downloadSelected() {
+ console.log('=== downloadSelected v1.2 - Using key as primary identifier ===');
+ if (this.selectedSeries.size === 0) {
+ this.showToast('No series selected', 'warning');
+ return;
+ }
+
+ try {
+ // selectedSeries now contains 'key' values (not folder)
+ const selectedKeys = Array.from(this.selectedSeries);
+ console.log('=== Starting download for selected series ===');
+ console.log('Selected keys:', selectedKeys);
+ console.log('seriesData:', this.seriesData);
+ let totalEpisodesAdded = 0;
+ let failedSeries = [];
+
+ // For each selected series, get its missing episodes and add to queue
+ // Use 'key' to find the series in seriesData
+ for (const key of selectedKeys) {
+ const serie = this.seriesData.find(s => s.key === key);
+ if (!serie || !serie.episodeDict) {
+ console.error('Serie not found or has no episodeDict for key:', key, serie);
+ failedSeries.push(key);
+ continue;
+ }
+
+ // Validate required fields
+ if (!serie.key) {
+ console.error('Serie missing key:', serie);
+ failedSeries.push(key);
+ continue;
+ }
+
+ // Convert episodeDict format {season: [episodes]} to episode identifiers
+ const episodes = [];
+ for (const [season, episodeNumbers] of Object.entries(serie.episodeDict)) {
+ if (Array.isArray(episodeNumbers)) {
+ for (const episode of episodeNumbers) {
+ episodes.push({
+ season: parseInt(season),
+ episode: episode
+ });
+ }
+ }
+ }
+
+ if (episodes.length === 0) {
+ console.log('No episodes to add for serie:', serie.name);
+ continue;
+ }
+
+ // Use folder name as fallback if serie name is empty
+ const serieName = serie.name && serie.name.trim() ? serie.name : serie.folder;
+
+ // Add episodes to download queue
+ const requestBody = {
+ serie_id: serie.key,
+ serie_folder: serie.folder,
+ serie_name: serieName,
+ episodes: episodes,
+ priority: 'NORMAL'
+ };
+ console.log('Sending queue add request:', requestBody);
+
+ const response = await this.makeAuthenticatedRequest('/api/queue/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ });
+
+ if (!response) {
+ failedSeries.push(key);
+ continue;
+ }
+
+ const data = await response.json();
+ console.log('Queue add response:', response.status, data);
+
+ // Log validation errors in detail
+ if (data.detail && Array.isArray(data.detail)) {
+ console.error('Validation errors:', JSON.stringify(data.detail, null, 2));
+ }
+
+ if (response.ok && data.status === 'success') {
+ totalEpisodesAdded += episodes.length;
+ } else {
+ console.error('Failed to add to queue:', data);
+ failedSeries.push(key);
+ }
+ }
+
+ // Show result message
+ console.log('=== Download request complete ===');
+ console.log('Total episodes added:', totalEpisodesAdded);
+ console.log('Failed series (keys):', failedSeries);
+
+ if (totalEpisodesAdded > 0) {
+ const message = failedSeries.length > 0
+ ? `Added ${totalEpisodesAdded} episode(s) to queue (${failedSeries.length} series failed)`
+ : `Added ${totalEpisodesAdded} episode(s) to download queue`;
+ this.showToast(message, 'success');
+ } else {
+ const errorDetails = failedSeries.length > 0
+ ? `Failed series (keys): ${failedSeries.join(', ')}`
+ : 'No episodes were added. Check browser console for details.';
+ console.error('Failed to add episodes. Details:', errorDetails);
+ this.showToast('Failed to add episodes to queue. Check console for details.', 'error');
+ }
+ } catch (error) {
+ console.error('Download error:', error);
+ this.showToast('Failed to start download', 'error');
+ }
+ }
+
+ async rescanSeries() {
+ try {
+ // Show the overlay immediately before making the API call
+ this.showScanProgressOverlay({
+ directory: 'Starting scan...',
+ total_items: 0
+ });
+ this.updateProcessStatus('rescan', true);
+
+ const response = await this.makeAuthenticatedRequest('/api/anime/rescan', {
+ method: 'POST'
+ });
+
+ if (!response) {
+ this.removeScanProgressOverlay();
+ this.updateProcessStatus('rescan', false);
+ return;
+ }
+ const data = await response.json();
+
+ // Debug logging
+ console.log('Rescan response:', data);
+ console.log('Success value:', data.success, 'Type:', typeof data.success);
+
+ // Note: The scan progress will be updated via WebSocket events
+ // The overlay will be closed when scan_completed is received
+ if (data.success !== true) {
+ this.removeScanProgressOverlay();
+ this.updateProcessStatus('rescan', false);
+ this.showToast(`Rescan error: ${data.message}`, 'error');
+ }
+ } catch (error) {
+ console.error('Rescan error:', error);
+ this.removeScanProgressOverlay();
+ this.updateProcessStatus('rescan', false);
+ this.showToast('Failed to start rescan', 'error');
+ }
+ }
+
+ showStatus(message, showProgress = false, showControls = false) {
+ const panel = document.getElementById('status-panel');
+ const messageEl = document.getElementById('status-message');
+ const progressContainer = document.getElementById('progress-container');
+ const controlsContainer = document.getElementById('download-controls');
+
+ messageEl.textContent = message;
+ progressContainer.classList.toggle('hidden', !showProgress);
+ controlsContainer.classList.toggle('hidden', !showControls);
+
+ if (showProgress) {
+ this.updateProgress(0);
+ }
+
+ panel.classList.remove('hidden');
+ }
+
+ updateStatus(message) {
+ document.getElementById('status-message').textContent = message;
+ }
+
+ updateProgress(percent, message = null) {
+ const fill = document.getElementById('progress-fill');
+ const text = document.getElementById('progress-text');
+
+ fill.style.width = `${percent}%`;
+ text.textContent = message || `${percent}%`;
+ }
+
+ hideStatus() {
+ document.getElementById('status-panel').classList.add('hidden');
+ }
+
+ /**
+ * Show the scan progress overlay with spinner and initial state
+ * @param {Object} data - Scan started event data
+ */
+ showScanProgressOverlay(data) {
+ // Remove existing overlay if present
+ this.removeScanProgressOverlay();
+
+ // Store total items for progress calculation
+ this.scanTotalItems = data?.total_items || 0;
+
+ // Store last scan data for reopening
+ this._lastScanData = data;
+
+ // Create overlay element
+ const overlay = document.createElement('div');
+ overlay.id = 'scan-progress-overlay';
+ overlay.className = 'scan-progress-overlay';
+
+ const totalDisplay = this.scanTotalItems > 0 ? this.scanTotalItems : '...';
+
+ overlay.innerHTML = `
+
+
+
+
+ 0 / ${totalDisplay} directories
+
+
+
+ 0
+ Scanned
+
+
+ 0
+ Series Found
+
+
+
+ Current:
+ ${this.escapeHtml(data?.directory || 'Initializing...')}
+
+
+
+ 0.0s
+
+
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Add click-outside-to-close handler
+ overlay.addEventListener('click', (e) => {
+ // Only close if clicking the overlay background, not the container
+ if (e.target === overlay) {
+ this.removeScanProgressOverlay();
+ }
+ });
+
+ // Trigger animation by adding visible class after a brief delay
+ requestAnimationFrame(() => {
+ overlay.classList.add('visible');
+ });
+ }
+
+ /**
+ * Update the scan progress overlay with current progress
+ * @param {Object} data - Scan progress event data
+ */
+ updateScanProgressOverlay(data) {
+ const overlay = document.getElementById('scan-progress-overlay');
+ if (!overlay) return;
+
+ // Update total items if provided (in case it wasn't available at start)
+ if (data.total_items && data.total_items > 0) {
+ this.scanTotalItems = data.total_items;
+ const totalCount = document.getElementById('scan-total-count');
+ if (totalCount) {
+ totalCount.textContent = this.scanTotalItems;
+ }
+ }
+
+ // Update progress bar
+ const progressBar = document.getElementById('scan-progress-bar');
+ if (progressBar && this.scanTotalItems > 0 && data.directories_scanned !== undefined) {
+ const percentage = Math.min(100, (data.directories_scanned / this.scanTotalItems) * 100);
+ progressBar.style.width = `${percentage}%`;
+ }
+
+ // Update current/total count display
+ const currentCount = document.getElementById('scan-current-count');
+ if (currentCount && data.directories_scanned !== undefined) {
+ currentCount.textContent = data.directories_scanned;
+ }
+
+ // Update directories count
+ const dirCount = document.getElementById('scan-directories-count');
+ if (dirCount && data.directories_scanned !== undefined) {
+ dirCount.textContent = data.directories_scanned;
+ }
+
+ // Update files/series count
+ const filesCount = document.getElementById('scan-files-count');
+ if (filesCount && data.files_found !== undefined) {
+ filesCount.textContent = data.files_found;
+ }
+
+ // Update current directory (truncate if too long)
+ const currentPath = document.getElementById('scan-current-path');
+ if (currentPath && data.current_directory) {
+ const maxLength = 50;
+ let displayPath = data.current_directory;
+ if (displayPath.length > maxLength) {
+ displayPath = '...' + displayPath.slice(-maxLength + 3);
+ }
+ currentPath.textContent = displayPath;
+ currentPath.title = data.current_directory; // Full path on hover
+ }
+ }
+
+ /**
+ * Hide the scan progress overlay with completion summary
+ * @param {Object} data - Scan completed event data
+ */
+ hideScanProgressOverlay(data) {
+ const overlay = document.getElementById('scan-progress-overlay');
+ if (!overlay) return;
+
+ const container = overlay.querySelector('.scan-progress-container');
+ if (container) {
+ container.classList.add('completed');
+ }
+
+ // Update title
+ const titleText = overlay.querySelector('.scan-title-text');
+ if (titleText) {
+ titleText.textContent = 'Scan Complete';
+ }
+
+ // Complete the progress bar
+ const progressBar = document.getElementById('scan-progress-bar');
+ if (progressBar) {
+ progressBar.style.width = '100%';
+ }
+
+ // Update final stats
+ if (data) {
+ const dirCount = document.getElementById('scan-directories-count');
+ if (dirCount && data.total_directories !== undefined) {
+ dirCount.textContent = data.total_directories;
+ }
+
+ const filesCount = document.getElementById('scan-files-count');
+ if (filesCount && data.total_files !== undefined) {
+ filesCount.textContent = data.total_files;
+ }
+
+ // Update progress text to show final count
+ const currentCount = document.getElementById('scan-current-count');
+ const totalCount = document.getElementById('scan-total-count');
+ if (currentCount && data.total_directories !== undefined) {
+ currentCount.textContent = data.total_directories;
+ }
+ if (totalCount && data.total_directories !== undefined) {
+ totalCount.textContent = data.total_directories;
+ }
+
+ // Show elapsed time
+ const elapsedTimeEl = document.getElementById('scan-elapsed-time');
+ const elapsedValueEl = document.getElementById('scan-elapsed-value');
+ if (elapsedTimeEl && elapsedValueEl && data.elapsed_seconds !== undefined) {
+ elapsedValueEl.textContent = `${data.elapsed_seconds.toFixed(1)}s`;
+ elapsedTimeEl.classList.remove('hidden');
+ }
+
+ // Update current directory to show completion message
+ const currentPath = document.getElementById('scan-current-path');
+ if (currentPath) {
+ currentPath.textContent = 'Scan finished successfully';
+ }
+ }
+
+ // Auto-dismiss after 3 seconds
+ setTimeout(() => {
+ this.removeScanProgressOverlay();
+ }, 3000);
+ }
+
+ /**
+ * Remove the scan progress overlay from the DOM
+ */
+ removeScanProgressOverlay() {
+ const overlay = document.getElementById('scan-progress-overlay');
+ if (overlay) {
+ overlay.classList.remove('visible');
+ // Wait for fade out animation before removing
+ setTimeout(() => {
+ if (overlay.parentElement) {
+ overlay.remove();
+ }
+ }, 300);
+ }
+ }
+
+ /**
+ * Reopen the scan progress overlay if a scan is in progress
+ * Called when user clicks on the rescan status indicator
+ */
+ async reopenScanOverlay() {
+ // Check if overlay already exists
+ const existingOverlay = document.getElementById('scan-progress-overlay');
+ if (existingOverlay) {
+ // Overlay is already open, do nothing
+ return;
+ }
+
+ // Check if scan is running via API
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
+ if (!response || !response.ok) {
+ console.log('Could not fetch scan status');
+ return;
+ }
+
+ const data = await response.json();
+ console.log('Scan status for reopen:', data);
+
+ if (data.is_scanning) {
+ // A scan is in progress, show the overlay
+ this.showScanProgressOverlay({
+ directory: data.directory,
+ total_items: data.total_items
+ });
+
+ // Update with current progress
+ this.updateScanProgressOverlay({
+ directories_scanned: data.directories_scanned,
+ files_found: data.directories_scanned,
+ current_directory: data.current_directory,
+ total_items: data.total_items
+ });
+ }
+ } catch (error) {
+ console.error('Error checking scan status for reopen:', error);
+ }
+ }
+
+ /**
+ * Check if a scan is currently in progress (useful after page reload)
+ * and show the progress overlay if so
+ */
+ async checkActiveScanStatus() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
+ if (!response || !response.ok) {
+ console.log('Could not fetch scan status, response:', response?.status);
+ return;
+ }
+
+ const data = await response.json();
+ console.log('Scan status check result:', data);
+
+ if (data.is_scanning) {
+ console.log('Scan is active, updating UI indicators');
+
+ // Update the process status indicator FIRST before showing overlay
+ // This ensures the header icon shows the running state immediately
+ this.updateProcessStatus('rescan', true);
+
+ // A scan is in progress, show the overlay
+ this.showScanProgressOverlay({
+ directory: data.directory,
+ total_items: data.total_items
+ });
+
+ // Update with current progress
+ this.updateScanProgressOverlay({
+ directories_scanned: data.directories_scanned,
+ files_found: data.directories_scanned,
+ current_directory: data.current_directory,
+ total_items: data.total_items
+ });
+
+ // Double-check the status indicator was updated
+ const statusElement = document.getElementById('rescan-status');
+ if (statusElement) {
+ console.log('Rescan status element classes:', statusElement.className);
+ } else {
+ console.warn('Rescan status element not found in DOM');
+ }
+ } else {
+ console.log('No active scan detected');
+ // Ensure indicator shows idle state
+ this.updateProcessStatus('rescan', false);
+ }
+ } catch (error) {
+ console.error('Error checking scan status:', error);
+ }
+ }
+
+ showLoading() {
+ document.getElementById('loading-overlay').classList.remove('hidden');
+ }
+
+ hideLoading() {
+ document.getElementById('loading-overlay').classList.add('hidden');
+ }
+
+ showToast(message, type = 'info') {
+ const container = document.getElementById('toast-container');
+ const toast = document.createElement('div');
+
+ toast.className = `toast ${type}`;
+ toast.innerHTML = `
+
+ ${this.escapeHtml(message)}
+
+
+ `;
+
+ container.appendChild(toast);
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => {
+ if (toast.parentElement) {
+ toast.remove();
+ }
+ }, 5000);
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Get display name for anime/series object.
+ * Returns name if available and not empty, otherwise returns key.
+ * @param {Object} anime - Anime/series object with name and key properties
+ * @returns {string} Display name
+ */
+ getDisplayName(anime) {
+ if (!anime) return '';
+ // Use name if it exists and is not empty (after trimming whitespace)
+ const name = anime.name || '';
+ const trimmedName = name.trim();
+ if (trimmedName) {
+ return trimmedName;
+ }
+ // Fallback to key
+ return anime.key || anime.folder || '';
+ }
+
+ updateConnectionStatus() {
+ const indicator = document.getElementById('connection-status-display');
+ if (indicator) {
+ const statusIndicator = indicator.querySelector('.status-indicator');
+ const statusText = indicator.querySelector('.status-text');
+
+ if (this.isConnected) {
+ statusIndicator.classList.add('connected');
+ statusText.textContent = this.localization.getText('connected');
+ } else {
+ statusIndicator.classList.remove('connected');
+ statusText.textContent = this.localization.getText('disconnected');
+ }
+ }
+ }
+
+ updateProcessStatus(processName, isRunning, hasError = false) {
+ const statusElement = document.getElementById(`${processName}-status`);
+ if (!statusElement) {
+ console.warn(`Process status element not found: ${processName}-status`);
+ return;
+ }
+
+ const statusDot = statusElement.querySelector('.status-dot');
+ if (!statusDot) {
+ console.warn(`Status dot not found in ${processName}-status element`);
+ return;
+ }
+
+ // Remove all status classes from both dot and element
+ statusDot.classList.remove('idle', 'running', 'error');
+ statusElement.classList.remove('running', 'error', 'idle');
+
+ // Capitalize process name for display
+ const displayName = processName.charAt(0).toUpperCase() + processName.slice(1);
+
+ if (hasError) {
+ statusDot.classList.add('error');
+ statusElement.classList.add('error');
+ statusElement.title = `${displayName} error - click for details`;
+ } else if (isRunning) {
+ statusDot.classList.add('running');
+ statusElement.classList.add('running');
+ statusElement.title = `${displayName} is running...`;
+ } else {
+ statusDot.classList.add('idle');
+ statusElement.classList.add('idle');
+ statusElement.title = `${displayName} is idle`;
+ }
+
+ console.log(`Process status updated: ${processName} = ${isRunning ? 'running' : (hasError ? 'error' : 'idle')}`);
+ }
+
+ async showConfigModal() {
+ const modal = document.getElementById('config-modal');
+
+ try {
+ // Load current status
+ const response = await this.makeAuthenticatedRequest('/api/anime/status');
+ if (!response) return;
+ const data = await response.json();
+
+ document.getElementById('anime-directory-input').value = data.directory || '';
+ document.getElementById('series-count-input').value = data.series_count || '0';
+
+ // Load scheduler configuration
+ await this.loadSchedulerConfig();
+
+ // Load logging configuration
+ await this.loadLoggingConfig();
+
+ // Load advanced configuration
+ await this.loadAdvancedConfig();
+
+ modal.classList.remove('hidden');
+ } catch (error) {
+ console.error('Error loading configuration:', error);
+ this.showToast('Failed to load configuration', 'error');
+ }
+ }
+
+ hideConfigModal() {
+ document.getElementById('config-modal').classList.add('hidden');
+ }
+
+ async loadSchedulerConfig() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ const config = data.config || {};
+ const schedulerStatus = data.status || {};
+
+ // Update UI elements
+ document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
+ document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
+ document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
+ const folderNaming = document.getElementById('folder-scan-enabled');
+ if (folderNaming) {
+ folderNaming.checked = !!config.folder_naming_after_nfo_scan;
+ }
+
+ // Update day-of-week checkboxes
+ const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
+ ['mon','tue','wed','thu','fri','sat','sun'].forEach(day => {
+ const cb = document.getElementById(`scheduler-day-${day}`);
+ if (cb) cb.checked = days.includes(day);
+ });
+
+ // Update status display
+ const nextRunEl = document.getElementById('scheduler-next-run');
+ if (nextRunEl) {
+ nextRunEl.textContent = schedulerStatus.next_run
+ ? new Date(schedulerStatus.next_run).toLocaleString()
+ : 'Not scheduled';
+ }
+ const lastRunEl = document.getElementById('last-rescan-time');
+ if (lastRunEl) {
+ lastRunEl.textContent = schedulerStatus.last_run
+ ? new Date(schedulerStatus.last_run).toLocaleString()
+ : 'Never';
+ }
+
+ const statusBadge = document.getElementById('scheduler-running-status');
+ if (statusBadge) {
+ statusBadge.textContent = schedulerStatus.is_running ? 'Running' : 'Stopped';
+ statusBadge.className = `info-value status-badge ${schedulerStatus.is_running ? 'running' : 'stopped'}`;
+ }
+
+ // Enable/disable time/day inputs based on checkbox
+ this.toggleSchedulerTimeInput();
+ }
+ } catch (error) {
+ console.error('Error loading scheduler config:', error);
+ this.showToast('Failed to load scheduler configuration', 'error');
+ }
+ }
+
+ async saveSchedulerConfig() {
+ try {
+ const enabled = document.getElementById('scheduled-rescan-enabled').checked;
+ const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
+ const autoDownload = document.getElementById('auto-download-after-rescan').checked;
+
+ // Collect checked day-of-week values
+ const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
+ .filter(day => {
+ const cb = document.getElementById(`scheduler-day-${day}`);
+ return cb ? cb.checked : true;
+ });
+
+ const response = await this.makeAuthenticatedRequest('/api/scheduler/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled: enabled,
+ schedule_time: scheduleTime,
+ schedule_days: scheduleDays,
+ auto_download_after_rescan: autoDownload,
+ folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
+ })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Scheduler configuration saved successfully', 'success');
+ // Update next-run display from response
+ const nextRunEl = document.getElementById('scheduler-next-run');
+ if (nextRunEl && data.status && data.status.next_run) {
+ nextRunEl.textContent = new Date(data.status.next_run).toLocaleString();
+ }
+ // Reload config to sync the full UI
+ await this.loadSchedulerConfig();
+ } else {
+ this.showToast(`Failed to save config: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error saving scheduler config:', error);
+ this.showToast('Failed to save scheduler configuration', 'error');
+ }
+ }
+
+ async testScheduledRescan() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/scheduler/trigger-rescan', {
+ method: 'POST'
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Test rescan triggered successfully', 'success');
+ } else {
+ this.showToast(`Failed to trigger test rescan: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error triggering test rescan:', error);
+ this.showToast('Failed to trigger test rescan', 'error');
+ }
+ }
+
+ toggleSchedulerTimeInput() {
+ const enabled = document.getElementById('scheduled-rescan-enabled').checked;
+ const timeConfig = document.getElementById('rescan-time-config');
+ const daysConfig = document.getElementById('rescan-days-config');
+ const nextRunEl = document.getElementById('scheduler-next-run');
+
+ if (timeConfig) {
+ timeConfig.classList.toggle('enabled', enabled);
+ }
+ if (daysConfig) {
+ daysConfig.classList.toggle('enabled', enabled);
+ }
+ if (nextRunEl) {
+ nextRunEl.parentElement && nextRunEl.parentElement.parentElement
+ ? nextRunEl.parentElement.parentElement.classList.toggle('hidden', !enabled)
+ : null;
+ }
+ }
+
+ async loadLoggingConfig() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/logging/config');
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.success) {
+ const config = data.config;
+
+ // Set form values
+ document.getElementById('log-level').value = config.log_level || 'INFO';
+ document.getElementById('enable-console-logging').checked = config.enable_console_logging !== false;
+ document.getElementById('enable-console-progress').checked = config.enable_console_progress === true;
+ document.getElementById('enable-fail2ban-logging').checked = config.enable_fail2ban_logging !== false;
+
+ // Load log files
+ await this.loadLogFiles();
+ }
+ } catch (error) {
+ console.error('Error loading logging config:', error);
+ this.showToast('Failed to load logging configuration', 'error');
+ }
+ }
+
+ async loadLogFiles() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/logging/files');
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.success) {
+ const container = document.getElementById('log-files-list');
+ container.innerHTML = '';
+
+ if (data.files.length === 0) {
+ container.innerHTML = 'No log files found
';
+ return;
+ }
+
+ data.files.forEach(file => {
+ const item = document.createElement('div');
+ item.className = 'log-file-item';
+
+ const info = document.createElement('div');
+ info.className = 'log-file-info';
+
+ const name = document.createElement('div');
+ name.className = 'log-file-name';
+ name.textContent = file.name;
+
+ const details = document.createElement('div');
+ details.className = 'log-file-details';
+ details.textContent = `Size: ${file.size_mb} MB • Modified: ${new Date(file.modified).toLocaleDateString()}`;
+
+ info.appendChild(name);
+ info.appendChild(details);
+
+ const actions = document.createElement('div');
+ actions.className = 'log-file-actions';
+
+ const downloadBtn = document.createElement('button');
+ downloadBtn.className = 'btn btn-xs btn-secondary';
+ downloadBtn.innerHTML = '';
+ downloadBtn.title = 'Download';
+ downloadBtn.onclick = () => this.downloadLogFile(file.name);
+
+ const viewBtn = document.createElement('button');
+ viewBtn.className = 'btn btn-xs btn-secondary';
+ viewBtn.innerHTML = '';
+ viewBtn.title = 'View Last 100 Lines';
+ viewBtn.onclick = () => this.viewLogFile(file.name);
+
+ actions.appendChild(downloadBtn);
+ actions.appendChild(viewBtn);
+
+ item.appendChild(info);
+ item.appendChild(actions);
+
+ container.appendChild(item);
+ });
+ }
+ } catch (error) {
+ console.error('Error loading log files:', error);
+ this.showToast('Failed to load log files', 'error');
+ }
+ }
+
+ async saveLoggingConfig() {
+ try {
+ const config = {
+ log_level: document.getElementById('log-level').value,
+ enable_console_logging: document.getElementById('enable-console-logging').checked,
+ enable_console_progress: document.getElementById('enable-console-progress').checked,
+ enable_fail2ban_logging: document.getElementById('enable-fail2ban-logging').checked
+ };
+
+ const response = await this.makeAuthenticatedRequest('/api/logging/config', {
+ method: 'POST',
+ body: JSON.stringify(config)
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Logging configuration saved successfully', 'success');
+ await this.loadLoggingConfig();
+ } else {
+ this.showToast(`Failed to save logging config: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error saving logging config:', error);
+ this.showToast('Failed to save logging configuration', 'error');
+ }
+ }
+
+ async testLogging() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/logging/test', {
+ method: 'POST'
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Test messages logged successfully', 'success');
+ setTimeout(() => this.loadLogFiles(), 1000); // Refresh log files after a second
+ } else {
+ this.showToast(`Failed to test logging: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error testing logging:', error);
+ this.showToast('Failed to test logging', 'error');
+ }
+ }
+
+ async loadAdvancedConfig() {
+ // Placeholder for advanced configuration loading
+ // This method is called by showConfigModal but doesn't need to do anything special yet
+ console.log('Advanced configuration loaded (placeholder)');
+ }
+
+ async cleanupLogs() {
+ const days = prompt('Delete log files older than how many days?', '30');
+ if (!days || isNaN(days) || days < 1) {
+ this.showToast('Invalid number of days', 'error');
+ return;
+ }
+
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/logging/cleanup', {
+ method: 'POST',
+ body: JSON.stringify({ days: parseInt(days) })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast(data.message, 'success');
+ await this.loadLogFiles();
+ } else {
+ this.showToast(`Failed to cleanup logs: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error cleaning up logs:', error);
+ this.showToast('Failed to cleanup logs', 'error');
+ }
+ }
+
+ downloadLogFile(filename) {
+ // Create download link
+ const link = document.createElement('a');
+ link.href = `/api/logging/files/${encodeURIComponent(filename)}/download`;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+
+ async viewLogFile(filename) {
+ try {
+ const response = await this.makeAuthenticatedRequest(`/api/logging/files/${encodeURIComponent(filename)}/tail?lines=100`);
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.success) {
+ // Create modal to show log content
+ const modal = document.createElement('div');
+ modal.className = 'modal';
+ modal.style.display = 'block';
+
+ const modalContent = document.createElement('div');
+ modalContent.className = 'modal-content';
+ modalContent.style.maxWidth = '80%';
+ modalContent.style.maxHeight = '80%';
+
+ const header = document.createElement('div');
+ header.innerHTML = `Log File: ${filename}
Showing last ${data.showing_lines} of ${data.total_lines} lines
`;
+
+ const content = document.createElement('pre');
+ content.style.maxHeight = '60vh';
+ content.style.overflow = 'auto';
+ content.style.backgroundColor = '#f5f5f5';
+ content.style.padding = '10px';
+ content.style.fontSize = '12px';
+ content.textContent = data.lines.join('\n');
+
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = 'Close';
+ closeBtn.className = 'btn btn-secondary';
+ closeBtn.onclick = () => document.body.removeChild(modal);
+
+ modalContent.appendChild(header);
+ modalContent.appendChild(content);
+ modalContent.appendChild(closeBtn);
+ modal.appendChild(modalContent);
+ document.body.appendChild(modal);
+
+ // Close on background click
+ modal.onclick = (e) => {
+ if (e.target === modal) {
+ document.body.removeChild(modal);
+ }
+ };
+ } else {
+ this.showToast(`Failed to view log file: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error viewing log file:', error);
+ this.showToast('Failed to view log file', 'error');
+ }
+ }
+
+ // Configuration Management Methods
+ async loadAdvancedConfig() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/config/section/advanced');
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.success) {
+ const config = data.config;
+ document.getElementById('max-concurrent-downloads').value = config.max_concurrent_downloads || 3;
+ document.getElementById('provider-timeout').value = config.provider_timeout || 30;
+ document.getElementById('enable-debug-mode').checked = config.enable_debug_mode === true;
+ }
+ } catch (error) {
+ console.error('Error loading advanced config:', error);
+ }
+ }
+
+ async saveAdvancedConfig() {
+ try {
+ const config = {
+ max_concurrent_downloads: parseInt(document.getElementById('max-concurrent-downloads').value),
+ provider_timeout: parseInt(document.getElementById('provider-timeout').value),
+ enable_debug_mode: document.getElementById('enable-debug-mode').checked
+ };
+
+ const response = await this.makeAuthenticatedRequest('/api/config/section/advanced', {
+ method: 'POST',
+ body: JSON.stringify(config)
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Advanced configuration saved successfully', 'success');
+ } else {
+ this.showToast(`Failed to save config: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error saving advanced config:', error);
+ this.showToast('Failed to save advanced configuration', 'error');
+ }
+ }
+
+ // Main Configuration Methods
+ async saveMainConfig() {
+ try {
+ const animeDirectory = document.getElementById('anime-directory-input').value.trim();
+
+ if (!animeDirectory) {
+ this.showToast('Please enter an anime directory path', 'error');
+ return;
+ }
+
+ const response = await this.makeAuthenticatedRequest('/api/config/directory', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ directory: animeDirectory
+ })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Main configuration saved successfully', 'success');
+ // Refresh the status to get updated series count
+ await this.refreshStatus();
+ } else {
+ this.showToast(`Failed to save configuration: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error saving main config:', error);
+ this.showToast('Failed to save main configuration', 'error');
+ }
+ }
+
+ async resetMainConfig() {
+ if (confirm('Are you sure you want to reset the main configuration? This will clear the anime directory.')) {
+ document.getElementById('anime-directory-input').value = '';
+ document.getElementById('series-count-input').value = '0';
+ this.showToast('Main configuration reset', 'info');
+ }
+ }
+
+ async testConnection() {
+ try {
+ this.showToast('Testing connection...', 'info');
+
+ const response = await this.makeAuthenticatedRequest('/api/diagnostics/network');
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ const networkStatus = data.data;
+ const connectionDiv = document.getElementById('connection-status-display');
+ const statusIndicator = connectionDiv.querySelector('.status-indicator');
+ const statusText = connectionDiv.querySelector('.status-text');
+
+ if (networkStatus.aniworld_reachable) {
+ statusIndicator.className = 'status-indicator connected';
+ statusText.textContent = 'Connected';
+ this.showToast('Connection test successful', 'success');
+ } else {
+ statusIndicator.className = 'status-indicator disconnected';
+ statusText.textContent = 'Disconnected';
+ this.showToast('Connection test failed', 'error');
+ }
+ } else {
+ this.showToast('Connection test failed', 'error');
+ }
+ } catch (error) {
+ console.error('Error testing connection:', error);
+ this.showToast('Connection test failed', 'error');
+ }
+ }
+
+ async browseDirectory() {
+ // This would typically open a native directory browser
+ // For web applications, we'll show a prompt for manual entry
+ const currentPath = document.getElementById('anime-directory-input').value;
+ const newPath = prompt('Enter the anime directory path:', currentPath);
+
+ if (newPath !== null && newPath.trim() !== '') {
+ document.getElementById('anime-directory-input').value = newPath.trim();
+ }
+ }
+
+ async refreshStatus() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/anime/status');
+ if (!response) return;
+ const data = await response.json();
+
+ document.getElementById('anime-directory-input').value = data.directory || '';
+ document.getElementById('series-count-input').value = data.series_count || '0';
+ } catch (error) {
+ console.error('Error refreshing status:', error);
+ }
+ }
+
+ async createConfigBackup() {
+ const backupName = prompt('Enter backup name (optional):');
+
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/config/backup', {
+ method: 'POST',
+ body: JSON.stringify({ name: backupName || '' })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast(`Backup created: ${data.filename}`, 'success');
+ } else {
+ this.showToast(`Failed to create backup: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error creating backup:', error);
+ this.showToast('Failed to create backup', 'error');
+ }
+ }
+
+ async viewConfigBackups() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/config/backups');
+ if (!response) return;
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showBackupsModal(data.backups);
+ } else {
+ this.showToast(`Failed to load backups: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error loading backups:', error);
+ this.showToast('Failed to load backups', 'error');
+ }
+ }
+
+ async validateConfig() {
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/config/validate', {
+ method: 'POST',
+ body: JSON.stringify({}) // Validate current config
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showValidationResults(data.validation);
+ } else {
+ this.showToast(`Validation failed: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error validating config:', error);
+ this.showToast('Failed to validate configuration', 'error');
+ }
+ }
+
+ showValidationResults(validation) {
+ const container = document.getElementById('validation-results');
+ container.innerHTML = '';
+ container.classList.remove('hidden');
+
+ if (validation.valid) {
+ const success = document.createElement('div');
+ success.className = 'validation-success';
+ success.innerHTML = ' Configuration is valid!';
+ container.appendChild(success);
+ } else {
+ const header = document.createElement('div');
+ header.innerHTML = 'Validation Issues Found:';
+ container.appendChild(header);
+ }
+
+ // Show errors
+ validation.errors.forEach(error => {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'validation-error';
+ errorDiv.innerHTML = ` Error: ${error}`;
+ container.appendChild(errorDiv);
+ });
+
+ // Show warnings
+ validation.warnings.forEach(warning => {
+ const warningDiv = document.createElement('div');
+ warningDiv.className = 'validation-warning';
+ warningDiv.innerHTML = ` Warning: ${warning}`;
+ container.appendChild(warningDiv);
+ });
+ }
+
+ async resetConfig() {
+ if (!confirm('Are you sure you want to reset all configuration to defaults? This cannot be undone (except by restoring a backup).')) {
+ return;
+ }
+
+ try {
+ const response = await this.makeAuthenticatedRequest('/api/config/reset', {
+ method: 'POST',
+ body: JSON.stringify({ preserve_security: true })
+ });
+
+ if (!response) return;
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Configuration reset to defaults', 'success');
+ // Reload the config modal
+ setTimeout(() => {
+ this.hideConfigModal();
+ this.showConfigModal();
+ }, 1000);
+ } else {
+ this.showToast(`Failed to reset config: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error resetting config:', error);
+ this.showToast('Failed to reset configuration', 'error');
+ }
+ }
+
+ showDownloadQueue(data) {
+ const queueSection = document.getElementById('download-queue-section');
+ const queueProgress = document.getElementById('queue-progress');
+
+ queueProgress.textContent = `0/${data.total_series} series`;
+ this.updateDownloadQueue({
+ queue: data.queue || [],
+ current_downloading: null,
+ stats: {
+ completed_series: 0,
+ total_series: data.total_series
+ }
+ });
+
+ queueSection.classList.remove('hidden');
+ }
+
+ hideDownloadQueue() {
+ const queueSection = document.getElementById('download-queue-section');
+ const currentDownload = document.getElementById('current-download');
+
+ queueSection.classList.add('hidden');
+ currentDownload.classList.add('hidden');
+ }
+
+ updateDownloadQueue(data) {
+ const queueList = document.getElementById('queue-list');
+ const currentDownload = document.getElementById('current-download');
+ const queueProgress = document.getElementById('queue-progress');
+
+ // Update overall progress
+ if (data.stats) {
+ queueProgress.textContent = `${data.stats.completed_series}/${data.stats.total_series} series`;
+ }
+
+ // Update current downloading
+ if (data.current_downloading) {
+ currentDownload.classList.remove('hidden');
+ document.getElementById('current-serie-name').textContent = this.getDisplayName(data.current_downloading);
+ document.getElementById('current-episode').textContent = `${data.current_downloading.missing_episodes} episodes remaining`;
+ } else {
+ currentDownload.classList.add('hidden');
+ }
+
+ // Update queue list
+ if (data.queue && data.queue.length > 0) {
+ queueList.innerHTML = data.queue.map((serie, index) => `
+
+
${index + 1}
+
${this.escapeHtml(this.getDisplayName(serie))}
+
Waiting
+
+ `).join('');
+ } else {
+ queueList.innerHTML = 'No series in queue
';
+ }
+ }
+
+ updateCurrentEpisode(data) {
+ const currentEpisode = document.getElementById('current-episode');
+ const progressFill = document.getElementById('current-progress-fill');
+ const progressText = document.getElementById('current-progress-text');
+
+ if (currentEpisode && data.episode) {
+ currentEpisode.textContent = `${data.episode} (${data.episode_progress})`;
+ }
+
+ // Update mini progress bar based on overall progress
+ if (data.overall_progress && progressFill && progressText) {
+ const [current, total] = data.overall_progress.split('/').map(n => parseInt(n));
+ const percent = total > 0 ? (current / total * 100).toFixed(1) : 0;
+
+ progressFill.style.width = `${percent}%`;
+ progressText.textContent = `${percent}%`;
+ }
+ }
+
+ updateDownloadProgress(data) {
+ const queueProgress = document.getElementById('queue-progress');
+
+ if (queueProgress && data.completed_series && data.total_series) {
+ queueProgress.textContent = `${data.completed_series}/${data.total_series} series`;
+ }
+
+ this.showToast(`Completed: ${data.serie}`, 'success');
+ }
+
+ initMobileAndAccessibility() {
+ // Initialize Mobile Responsive Manager
+ if (typeof MobileResponsiveManager !== 'undefined') {
+ this.mobileResponsive = new MobileResponsiveManager();
+ }
+
+ // Initialize Touch Gesture Manager
+ if (typeof TouchGestureManager !== 'undefined') {
+ this.touchGestures = new TouchGestureManager();
+ }
+
+ // Initialize Accessibility Manager
+ if (typeof AccessibilityManager !== 'undefined') {
+ this.accessibility = new AccessibilityManager();
+ }
+
+ // Initialize Screen Reader Manager
+ if (typeof ScreenReaderManager !== 'undefined') {
+ this.screenReader = new ScreenReaderManager();
+ }
+
+ // Initialize Color Contrast Manager
+ if (typeof ColorContrastManager !== 'undefined') {
+ this.colorContrast = new ColorContrastManager();
+ }
+
+ // Initialize Multi-Screen Manager
+ if (typeof MultiScreenManager !== 'undefined') {
+ this.multiScreen = new MultiScreenManager();
+ }
+
+ console.log('Mobile & Accessibility features initialized');
+ }
+
+ formatETA(seconds) {
+ if (!seconds || seconds <= 0) return '---';
+
+ if (seconds < 60) {
+ return `${Math.round(seconds)}s`;
+ } else if (seconds < 3600) {
+ const minutes = Math.round(seconds / 60);
+ return `${minutes}m`;
+ } else if (seconds < 86400) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.round((seconds % 3600) / 60);
+ return `${hours}h ${minutes}m`;
+ } else {
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.round((seconds % 86400) / 3600);
+ return `${days}d ${hours}h`;
+ }
+ }
+}
+
+// Initialize the application when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ window.app = new AniWorldApp();
+});
+
+// Global functions for inline event handlers
window.app = null;
\ No newline at end of file
diff --git a/src/server/web/static/js/index/scheduler-config.js b/src/server/web/static/js/index/scheduler-config.js
index a4c07dc..72aeadb 100644
--- a/src/server/web/static/js/index/scheduler-config.js
+++ b/src/server/web/static/js/index/scheduler-config.js
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
autoDownload.checked = config.auto_download_after_rescan || false;
}
+ const folderNaming = document.getElementById('folder-scan-enabled');
+ if (folderNaming) {
+ folderNaming.checked = config.folder_naming_after_nfo_scan || false;
+ }
+
// Update schedule day checkboxes
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
enabled: enabled,
schedule_time: scheduleTime,
schedule_days: scheduleDays,
- auto_download_after_rescan: autoDownload
+ auto_download_after_rescan: autoDownload,
+ folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
};
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
diff --git a/src/server/web/templates/setup.html b/src/server/web/templates/setup.html
index 7f0e972..bf265e9 100644
--- a/src/server/web/templates/setup.html
+++ b/src/server/web/templates/setup.html
@@ -479,6 +479,13 @@
Auto-download missing episodes after rescan
+
@@ -761,6 +768,7 @@
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
+ scheduler_folder_naming_after_nfo_scan: document.getElementById('scheduler_folder_naming').checked,
logging_level: document.getElementById('logging_level').value,
logging_file: document.getElementById('logging_file').value.trim() || null,
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
diff --git a/tests/unit/test_folder_naming_service.py b/tests/unit/test_folder_naming_service.py
new file mode 100644
index 0000000..cc2f1d8
--- /dev/null
+++ b/tests/unit/test_folder_naming_service.py
@@ -0,0 +1,344 @@
+"""Tests for FolderNamingService."""
+import os
+import re
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from src.server.services.folder_naming_service import (
+ FolderNamingService,
+ FolderRenameResult,
+ FolderNamingReport,
+)
+
+
+# -----------------------------------------------------------------------
+# Static method tests
+# -----------------------------------------------------------------------
+
+class TestFolderHasYear:
+ """Tests for _folder_has_year."""
+
+ @pytest.mark.parametrize(
+ ("folder", "expected"),
+ [
+ ("Naruto", False),
+ ("Naruto (2020)", True),
+ ("Attack on Titan (2013)", True),
+ ("", False),
+ ("One Piece", False),
+ ("(2020)", True),
+ ("Naruto 2020", False), # no parentheses
+ ("Naruto (20)", False), # too short
+ ("Naruto (abcd)", False), # not digits
+ ],
+ )
+ def test_folder_has_year(self, folder, expected):
+ assert FolderNamingService._folder_has_year(folder) == expected
+
+
+class TestExtractYearFromFolderName:
+ """Tests for _extract_year_from_folder_name."""
+
+ @pytest.mark.parametrize(
+ ("folder", "expected"),
+ [
+ ("Naruto", None),
+ ("Naruto (2020)", 2020),
+ ("Attack on Titan (2013)", 2013),
+ ("", None),
+ ("(2020)", 2020),
+ ("Title (1800)", None), # out of range
+ ("Title (2200)", None), # out of range
+ ("Title (2020) - Extra", 2020), # year in middle
+ ],
+ )
+ def test_extract_year(self, folder, expected):
+ assert FolderNamingService._extract_year_from_folder_name(folder) == expected
+
+
+class TestExtractTitleFromFolderName:
+ """Tests for _extract_title_from_folder_name."""
+
+ @pytest.mark.parametrize(
+ ("folder", "expected"),
+ [
+ ("Naruto", "Naruto"),
+ ("Naruto (2020)", "Naruto"),
+ ("Attack on Titan (2013)", "Attack on Titan"),
+ ("", ""),
+ ("Naruto (2020) ", "Naruto"), # extra spaces
+ ("Title (2020) - Extra", "Title (2020) - Extra"), # year in middle not removed
+ ],
+ )
+ def test_extract_title(self, folder, expected):
+ assert FolderNamingService._extract_title_from_folder_name(folder) == expected
+
+
+class TestBuildTargetFolder:
+ """Tests for _build_target_folder — the critical double-year prevention."""
+
+ @pytest.mark.parametrize(
+ ("folder", "year", "expected"),
+ [
+ # No year → add year
+ ("Naruto", 1999, "Naruto (1999)"),
+ ("One Piece", 1999, "One Piece (1999)"),
+ # Has year → strip and replace
+ ("Naruto (2020)", 1999, "Naruto (1999)"),
+ ("Naruto (2020)", 2020, "Naruto (2020)"),
+ # Has year with extra spaces
+ ("Naruto (2020)", 1999, "Naruto (1999)"),
+ # Title with inner parens (not a year) → preserved
+ ("Dragon Ball (Super)", 1989, "Dragon Ball (Super) (1989)"),
+ ],
+ )
+ def test_build_target_folder(self, folder, year, expected):
+ assert FolderNamingService._build_target_folder(folder, year) == expected
+
+ def test_repeated_calls_do_not_accumulate_years(self):
+ """Ensures safe: Naruto -> (1999) -> (1999) stays (1999), not (1999)(1999)."""
+ folder = "Naruto"
+ year = 1999
+ step1 = FolderNamingService._build_target_folder(folder, year)
+ step2 = FolderNamingService._build_target_folder(step1, year)
+ step3 = FolderNamingService._build_target_folder(step2, year)
+ assert step1 == "Naruto (1999)"
+ assert step2 == "Naruto (1999)"
+ assert step3 == "Naruto (1999)"
+
+ def test_already_yeared_folder_year_replaced_not_appended(self):
+ """Folder 'Naruto (2020)' with year=1999 → 'Naruto (1999)', not 'Naruto (2020) (1999)'."""
+ result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
+ assert result == "Naruto (1999)"
+ assert "2020" not in result
+
+ def test_different_years_each_call_is_safe(self):
+ """Multiple different years applied sequentially always produce clean name."""
+ folder = "Naruto"
+ r1 = FolderNamingService._build_target_folder(folder, 1999)
+ r2 = FolderNamingService._build_target_folder(r1, 1997)
+ r3 = FolderNamingService._build_target_folder(r2, 1999)
+ assert r1 == "Naruto (1999)"
+ assert r2 == "Naruto (1997)"
+ assert r3 == "Naruto (1999)"
+
+
+# -----------------------------------------------------------------------
+# Service tests (need mock DB + filesystem)
+# -----------------------------------------------------------------------
+
+@pytest.fixture
+def mock_db_session():
+ """Mock async DB session."""
+ with patch("src.server.services.folder_naming_service._get_db_session") as mock:
+ session = AsyncMock()
+ mock.return_value.__aenter__.return_value = session
+ mock.return_value.__aexit__.return_value = None
+ yield session
+
+
+@pytest.fixture
+def mock_series(flash=False):
+ """Factory for mock anime series objects."""
+ def _make(key, folder, year):
+ series = MagicMock()
+ series.key = key
+ series.folder = folder
+ series.year = year
+ return series
+ return _make
+
+
+@pytest.fixture
+def mock_settings(tmp_path):
+ """Mock settings with a temp anime directory."""
+ with patch("src.server.services.folder_naming_service.settings") as mock:
+ mock.anime_directory = str(tmp_path)
+ yield mock
+
+
+class TestFolderNamingServiceIntegration:
+ """Integration tests with mocked filesystem and DB."""
+
+ @pytest.mark.asyncio
+ async def test_skips_when_folder_already_has_year(
+ self, mock_db_session, mock_series, mock_settings
+ ):
+ """Folder 'Naruto (1999)' with DB year=1999 → skipped."""
+ series = mock_series("key1", "Naruto (1999)", 1999)
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ # Mock AnimeSeriesService.get_all
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
+ mock_get_all.return_value = [series]
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ assert report.total == 1
+ assert report.skipped == 1
+ assert report.renamed == 0
+ assert report.errors == 0
+ assert report.results[0].reason == "folder already has year"
+
+ @pytest.mark.asyncio
+ async def test_skips_when_db_has_no_year(
+ self, mock_db_session, mock_series, mock_settings
+ ):
+ """Folder 'Naruto' with DB year=None → skipped."""
+ series = mock_series("key1", "Naruto", None)
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
+ mock_get_all.return_value = [series]
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ assert report.skipped == 1
+ assert report.results[0].reason == "no year in DB record"
+
+ @pytest.mark.asyncio
+ async def test_renames_folder_and_updates_db(
+ self, tmp_path, mock_db_session, mock_series, mock_settings
+ ):
+ """Folder 'Naruto' with DB year=1999 → renames to 'Naruto (1999)'."""
+ anime_dir = tmp_path
+ (anime_dir / "Naruto").mkdir()
+ (anime_dir / "Naruto" / "episode1.mp4").touch()
+
+ series = mock_series("key1", "Naruto", 1999)
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
+ patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
+ patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
+ patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
+
+ mock_get_all.return_value = [series]
+ db_series = MagicMock()
+ db_series.id = 42
+ mock_get_by_key.return_value = db_series
+
+ app_instance = MagicMock()
+ app_instance.list.keyDict = {"key1": MagicMock()}
+ mock_get_app.return_value = app_instance
+
+ mock_settings.anime_directory = str(anime_dir)
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ assert report.renamed == 1
+ assert report.skipped == 0
+ assert report.errors == 0
+ assert report.results[0].new_folder == "Naruto (1999)"
+ assert (anime_dir / "Naruto (1999)").exists()
+ assert not (anime_dir / "Naruto").exists()
+ mock_update.assert_called_once()
+ call_kwargs = mock_update.call_args.kwargs
+ assert call_kwargs["folder"] == "Naruto (1999)"
+
+ @pytest.mark.asyncio
+ async def test_skips_when_target_folder_already_exists(
+ self, tmp_path, mock_db_session, mock_series, mock_settings
+ ):
+ """If 'Naruto (1999)' already exists, rename is skipped."""
+ anime_dir = tmp_path
+ (anime_dir / "Naruto").mkdir()
+ (anime_dir / "Naruto (1999)").mkdir() # target already exists
+
+ series = mock_series("key1", "Naruto", 1999)
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
+ mock_get_all.return_value = [series]
+ mock_settings.anime_directory = str(anime_dir)
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ assert report.errors == 1
+ assert report.renamed == 0
+ assert report.results[0].reason == "target folder already exists on disk"
+ assert (anime_dir / "Naruto").exists() # source not moved
+
+ @pytest.mark.asyncio
+ async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
+ """Safety guard triggers when _build_target_folder produces wrong year.
+
+ Uses a folder without a detectible year so _process_series proceeds to
+ build a target, then the safety re-check finds a mismatch.
+ """
+ anime_dir = tmp_path
+ (anime_dir / "Naruto").mkdir()
+
+ series = mock_series("key1", "Naruto", 1999)
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ # Patch _build_target_folder to return a folder whose extracted year != DB year
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
+ patch.object(FolderNamingService, "_build_target_folder", return_value="Naruto (2020)"):
+ mock_get_all.return_value = [series]
+ mock_settings.anime_directory = str(anime_dir)
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ # Should be skipped by safety guard
+ assert report.results[0].skipped is True
+ assert "safety guard" in report.results[0].reason.lower()
+ # Folder should NOT have been renamed
+ assert (anime_dir / "Naruto").exists()
+ assert not (anime_dir / "Naruto (2020)").exists()
+
+ @pytest.mark.asyncio
+ async def test_multiple_series_mixed_results(
+ self, tmp_path, mock_db_session, mock_series, mock_settings
+ ):
+ """Series 1 needs rename, series 2 is skipped (has year), series 3 has no year."""
+ anime_dir = tmp_path
+ (anime_dir / "Naruto").mkdir()
+ (anime_dir / "One Piece (1999)").mkdir()
+ (anime_dir / "Bleach").mkdir()
+
+ series = [
+ mock_series("key1", "Naruto", 1999),
+ mock_series("key2", "One Piece (1999)", 1999),
+ mock_series("key3", "Bleach", None),
+ ]
+ mock_db_session.__aenter__.return_value.__aexit__.return_value = None
+
+ with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
+ patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
+ patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
+ patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
+
+ mock_get_all.return_value = series
+ db_series = MagicMock()
+ db_series.id = 1
+ mock_get_by_key.return_value = db_series
+
+ app_instance = MagicMock()
+ app_instance.list.keyDict = {"key1": MagicMock()}
+ mock_get_app.return_value = app_instance
+ mock_settings.anime_directory = str(anime_dir)
+
+ service = FolderNamingService()
+ report = await service.run()
+
+ assert report.total == 3
+ assert report.renamed == 1 # Naruto → Naruto (1999)
+ assert report.skipped == 2 # One Piece (has year), Bleach (no year)
+ assert report.errors == 0
+
+
+class TestFolderNamingReport:
+ def test_to_dict(self):
+ result = FolderRenameResult(key="k1", old_folder="Naruto", new_folder="Naruto (1999)", success=True, skipped=False)
+ report = FolderNamingReport(total=1, renamed=1, skipped=0, errors=0, results=[result])
+ d = report.to_dict()
+ assert d["total"] == 1
+ assert d["renamed"] == 1
+ assert d["results"][0]["new_folder"] == "Naruto (1999)"