From 5f46d2e802fcf2f5bd6553efc1a6dfa22227cedb Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 26 Jul 2026 21:45:08 +0200 Subject: [PATCH] feat: add folder naming service to fix missing years in anime folder names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs after NFO refresh during scheduled rescans. Renames folders that are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year from the database record. Safety: _build_target_folder() always strips any existing year suffix first, preventing double/triple year accumulation like 'Naruto (1999) (1999) (1999)'. Changes: - New FolderNamingService (folder_naming_service.py) with safe target name construction, DB update, and in-memory cache update - New SchedulerConfig field: folder_naming_after_nfo_scan (default True) - Integrated as step 3 in scheduler _perform_rescan() after NFO scan - Runtime UI: existing 'folder-scan-enabled' checkbox in index.html now wired to toggle the feature (app.js + scheduler-config.js) - Setup screen: new checkbox in setup.html Scheduler Settings section - API: scheduler config endpoint returns all scan toggles - Tests: 39 unit tests covering static helpers, rename logic, safety guard, and integration cases (folder_naming_service.py) - Docs: testing guide updated with FolderNamingService examples --- Docs/TESTING.md | 109 +- src/server/api/scheduler.py | 3 + src/server/models/config.py | 6 + src/server/services/folder_naming_service.py | 205 + .../services/scheduler/scheduler_service.py | 25 +- src/server/web/static/js/app.js | 4713 +++++++++-------- .../web/static/js/index/scheduler-config.js | 8 +- src/server/web/templates/setup.html | 8 + tests/unit/test_folder_naming_service.py | 344 ++ 9 files changed, 2974 insertions(+), 2447 deletions(-) create mode 100644 src/server/services/folder_naming_service.py create mode 100644 tests/unit/test_folder_naming_service.py 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 = ` -
- -

${message}

-
- `; - 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 ` -
-
- -
-

${this.escapeHtml(this.getDisplayName(serie))}

-
${this.escapeHtml(serie.folder)}
-
-
- ${hasMissingEpisodes ? - '' : - '' - } -
-
-
-
- - ${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 = ` -
-
-

- - - Scanning Library -

-
-
-
-
-
- 0 / ${totalDisplay} directories -
-
-
- 0 - Scanned -
-
- 0 - Series Found -
-
-
- Current: - ${this.escapeHtml(data?.directory || 'Initializing...')} -
- -
- `; - - 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 = ` +
+ +

${message}

+
+ `; + 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 ` +
+
+ +
+

${this.escapeHtml(this.getDisplayName(serie))}

+
${this.escapeHtml(serie.folder)}
+
+
+ ${hasMissingEpisodes ? + '' : + '' + } +
+
+
+
+ + ${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 = ` +
+
+

+ + + Scanning Library +

+
+
+
+
+
+ 0 / ${totalDisplay} directories +
+
+
+ 0 + Scanned +
+
+ 0 + Series Found +
+
+
+ Current: + ${this.escapeHtml(data?.directory || 'Initializing...')} +
+ +
+ `; + + 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 +
+ +
Renames folders (e.g. "Naruto" → "Naruto (1999)") using the year from the database.
+
@@ -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)"