1361 lines
50 KiB
Python
1361 lines
50 KiB
Python
"""
|
|
Advanced Search and Filters Manager
|
|
|
|
This module provides advanced search functionality, filtering capabilities,
|
|
and search result management for the AniWorld web interface.
|
|
"""
|
|
|
|
from typing import Dict, List, Any, Optional, Set
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from flask import Blueprint, request, jsonify
|
|
import json
|
|
|
|
class AdvancedSearchManager:
|
|
"""Manages advanced search and filtering functionality."""
|
|
|
|
def __init__(self, app=None):
|
|
self.app = app
|
|
self.search_history = []
|
|
self.saved_searches = {}
|
|
self.filter_presets = {
|
|
'recent': {
|
|
'name': 'Recently Added',
|
|
'filters': {
|
|
'date_added': {'operator': 'last_days', 'value': 7}
|
|
}
|
|
},
|
|
'downloading': {
|
|
'name': 'Currently Downloading',
|
|
'filters': {
|
|
'status': {'operator': 'equals', 'value': 'downloading'}
|
|
}
|
|
},
|
|
'completed': {
|
|
'name': 'Completed Series',
|
|
'filters': {
|
|
'status': {'operator': 'equals', 'value': 'completed'}
|
|
}
|
|
},
|
|
'high_rated': {
|
|
'name': 'Highly Rated',
|
|
'filters': {
|
|
'rating': {'operator': 'greater_than', 'value': 8.0}
|
|
}
|
|
}
|
|
}
|
|
|
|
def init_app(self, app):
|
|
"""Initialize with Flask app."""
|
|
self.app = app
|
|
|
|
def get_search_js(self):
|
|
"""Generate JavaScript code for advanced search functionality."""
|
|
return f"""
|
|
// AniWorld Advanced Search Manager
|
|
class AdvancedSearchManager {{
|
|
constructor() {{
|
|
this.currentFilters = {{}};
|
|
this.searchHistory = [];
|
|
this.savedSearches = {{}};
|
|
this.filterPresets = {json.dumps(self.filter_presets)};
|
|
this.searchResults = [];
|
|
this.sortBy = 'name';
|
|
this.sortOrder = 'asc';
|
|
this.init();
|
|
}}
|
|
|
|
init() {{
|
|
this.createSearchInterface();
|
|
this.setupSearchEvents();
|
|
this.loadSearchHistory();
|
|
this.loadSavedSearches();
|
|
this.setupKeyboardShortcuts();
|
|
}}
|
|
|
|
createSearchInterface() {{
|
|
this.createSearchBar();
|
|
this.createAdvancedFilters();
|
|
this.createSearchResults();
|
|
this.createQuickFilters();
|
|
}}
|
|
|
|
createSearchBar() {{
|
|
const existingSearch = document.querySelector('.advanced-search-bar');
|
|
if (existingSearch) return;
|
|
|
|
const searchContainer = document.createElement('div');
|
|
searchContainer.className = 'advanced-search-bar mb-4';
|
|
searchContainer.innerHTML = `
|
|
<div class="row g-2">
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<input type="text" class="form-control" id="search-input"
|
|
placeholder="Search series, genres, year..."
|
|
autocomplete="off">
|
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
|
data-bs-toggle="dropdown" id="search-history-btn">
|
|
<i class="fas fa-history"></i>
|
|
</button>
|
|
<ul class="dropdown-menu" id="search-history-menu">
|
|
<li><h6 class="dropdown-header">Recent Searches</h6></li>
|
|
</ul>
|
|
<button class="btn btn-primary" type="button" id="search-btn">
|
|
<i class="fas fa-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<select class="form-select" id="search-type">
|
|
<option value="all">All Fields</option>
|
|
<option value="name">Series Name</option>
|
|
<option value="genre">Genre</option>
|
|
<option value="year">Year</option>
|
|
<option value="status">Status</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="btn-group w-100">
|
|
<button class="btn btn-outline-secondary" id="advanced-filters-btn">
|
|
<i class="fas fa-filter"></i> Filters
|
|
</button>
|
|
<button class="btn btn-outline-secondary" id="save-search-btn">
|
|
<i class="fas fa-save"></i>
|
|
</button>
|
|
<button class="btn btn-outline-secondary" id="clear-search-btn">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Insert at the top of main content
|
|
const mainContent = document.querySelector('.main-content, .container-fluid');
|
|
if (mainContent) {{
|
|
mainContent.insertBefore(searchContainer, mainContent.firstChild);
|
|
}}
|
|
}}
|
|
|
|
createAdvancedFilters() {{
|
|
const filtersModal = document.createElement('div');
|
|
filtersModal.id = 'advanced-filters-modal';
|
|
filtersModal.className = 'modal fade';
|
|
filtersModal.innerHTML = `
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Advanced Filters</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6>Content Filters</h6>
|
|
<div class="mb-3">
|
|
<label class="form-label">Genre</label>
|
|
<select class="form-select" id="filter-genre" multiple>
|
|
<option value="action">Action</option>
|
|
<option value="adventure">Adventure</option>
|
|
<option value="comedy">Comedy</option>
|
|
<option value="drama">Drama</option>
|
|
<option value="fantasy">Fantasy</option>
|
|
<option value="romance">Romance</option>
|
|
<option value="sci-fi">Sci-Fi</option>
|
|
<option value="thriller">Thriller</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Year Range</label>
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-year-from"
|
|
placeholder="From" min="1950" max="2030">
|
|
</div>
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-year-to"
|
|
placeholder="To" min="1950" max="2030">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Rating Range</label>
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-rating-min"
|
|
placeholder="Min" min="0" max="10" step="0.1">
|
|
</div>
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-rating-max"
|
|
placeholder="Max" min="0" max="10" step="0.1">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6>Status & Progress</h6>
|
|
<div class="mb-3">
|
|
<label class="form-label">Status</label>
|
|
<select class="form-select" id="filter-status" multiple>
|
|
<option value="not_started">Not Started</option>
|
|
<option value="downloading">Downloading</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="paused">Paused</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Episode Count Range</label>
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-episodes-min"
|
|
placeholder="Min episodes" min="0">
|
|
</div>
|
|
<div class="col-6">
|
|
<input type="number" class="form-control" id="filter-episodes-max"
|
|
placeholder="Max episodes" min="0">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Date Added</label>
|
|
<select class="form-select" id="filter-date-added">
|
|
<option value="">Any time</option>
|
|
<option value="today">Today</option>
|
|
<option value="week">This week</option>
|
|
<option value="month">This month</option>
|
|
<option value="custom">Custom range...</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3 d-none" id="custom-date-range">
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<input type="date" class="form-control" id="filter-date-from">
|
|
</div>
|
|
<div class="col-6">
|
|
<input type="date" class="form-control" id="filter-date-to">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6>Filter Presets</h6>
|
|
<div class="btn-group-vertical w-100" id="filter-presets">
|
|
<!-- Presets will be populated dynamically -->
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6>Saved Searches</h6>
|
|
<div id="saved-searches-list">
|
|
<!-- Saved searches will be populated dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-outline-danger" id="clear-filters-btn">Clear All</button>
|
|
<button type="button" class="btn btn-primary" id="apply-filters-btn">Apply Filters</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(filtersModal);
|
|
|
|
// Populate filter presets
|
|
this.populateFilterPresets();
|
|
}}
|
|
|
|
createSearchResults() {{
|
|
const existingResults = document.querySelector('.search-results-container');
|
|
if (existingResults) return;
|
|
|
|
const resultsContainer = document.createElement('div');
|
|
resultsContainer.className = 'search-results-container';
|
|
resultsContainer.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="search-info">
|
|
<span class="results-count">0 results</span>
|
|
<span class="search-query text-muted"></span>
|
|
</div>
|
|
<div class="sort-controls">
|
|
<select class="form-select form-select-sm d-inline-block w-auto" id="sort-by">
|
|
<option value="name">Name</option>
|
|
<option value="year">Year</option>
|
|
<option value="rating">Rating</option>
|
|
<option value="episodes">Episodes</option>
|
|
<option value="date_added">Date Added</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-outline-secondary" id="sort-order-btn">
|
|
<i class="fas fa-sort-alpha-down"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="search-results" id="search-results">
|
|
<!-- Results will be populated here -->
|
|
</div>
|
|
<div class="search-pagination mt-3" id="search-pagination">
|
|
<!-- Pagination will be added here -->
|
|
</div>
|
|
`;
|
|
|
|
const mainContent = document.querySelector('.main-content, .container-fluid');
|
|
if (mainContent) {{
|
|
mainContent.appendChild(resultsContainer);
|
|
}}
|
|
}}
|
|
|
|
createQuickFilters() {{
|
|
const quickFiltersContainer = document.createElement('div');
|
|
quickFiltersContainer.className = 'quick-filters mb-3';
|
|
quickFiltersContainer.innerHTML = `
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<span class="fw-bold me-2">Quick Filters:</span>
|
|
<button class="btn btn-sm btn-outline-primary filter-tag" data-preset="recent">
|
|
<i class="fas fa-clock"></i> Recent
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-success filter-tag" data-preset="downloading">
|
|
<i class="fas fa-download"></i> Downloading
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-info filter-tag" data-preset="completed">
|
|
<i class="fas fa-check-circle"></i> Completed
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-warning filter-tag" data-preset="high_rated">
|
|
<i class="fas fa-star"></i> High Rated
|
|
</button>
|
|
</div>
|
|
<div class="active-filters mt-2" id="active-filters">
|
|
<!-- Active filters will be shown here -->
|
|
</div>
|
|
`;
|
|
|
|
const searchBar = document.querySelector('.advanced-search-bar');
|
|
if (searchBar) {{
|
|
searchBar.parentNode.insertBefore(quickFiltersContainer, searchBar.nextSibling);
|
|
}}
|
|
}}
|
|
|
|
setupSearchEvents() {{
|
|
// Search input events
|
|
const searchInput = document.getElementById('search-input');
|
|
if (searchInput) {{
|
|
searchInput.addEventListener('input', this.handleSearchInput.bind(this));
|
|
searchInput.addEventListener('keypress', (e) => {{
|
|
if (e.key === 'Enter') {{
|
|
this.performSearch();
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
// Search button
|
|
document.getElementById('search-btn')?.addEventListener('click', this.performSearch.bind(this));
|
|
|
|
// Advanced filters button
|
|
document.getElementById('advanced-filters-btn')?.addEventListener('click', this.showAdvancedFilters.bind(this));
|
|
|
|
// Clear search button
|
|
document.getElementById('clear-search-btn')?.addEventListener('click', this.clearSearch.bind(this));
|
|
|
|
// Save search button
|
|
document.getElementById('save-search-btn')?.addEventListener('click', this.saveCurrentSearch.bind(this));
|
|
|
|
// Sort controls
|
|
document.getElementById('sort-by')?.addEventListener('change', this.handleSortChange.bind(this));
|
|
document.getElementById('sort-order-btn')?.addEventListener('click', this.toggleSortOrder.bind(this));
|
|
|
|
// Quick filter tags
|
|
document.addEventListener('click', (e) => {{
|
|
if (e.target.classList.contains('filter-tag')) {{
|
|
this.applyFilterPreset(e.target.dataset.preset);
|
|
}}
|
|
}});
|
|
|
|
// Filter date change
|
|
document.getElementById('filter-date-added')?.addEventListener('change', (e) => {{
|
|
const customRange = document.getElementById('custom-date-range');
|
|
if (e.target.value === 'custom') {{
|
|
customRange.classList.remove('d-none');
|
|
}} else {{
|
|
customRange.classList.add('d-none');
|
|
}}
|
|
}});
|
|
|
|
// Apply filters button
|
|
document.getElementById('apply-filters-btn')?.addEventListener('click', this.applyAdvancedFilters.bind(this));
|
|
|
|
// Clear filters button
|
|
document.getElementById('clear-filters-btn')?.addEventListener('click', this.clearAllFilters.bind(this));
|
|
}}
|
|
|
|
setupKeyboardShortcuts() {{
|
|
document.addEventListener('keydown', (e) => {{
|
|
if (e.ctrlKey || e.metaKey) {{
|
|
switch(e.key) {{
|
|
case 'f':
|
|
e.preventDefault();
|
|
document.getElementById('search-input')?.focus();
|
|
break;
|
|
case 'k':
|
|
e.preventDefault();
|
|
this.showAdvancedFilters();
|
|
break;
|
|
}}
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
handleSearchInput(e) {{
|
|
const query = e.target.value;
|
|
|
|
// Show suggestions after 2 characters
|
|
if (query.length >= 2) {{
|
|
this.showSearchSuggestions(query);
|
|
}} else {{
|
|
this.hideSearchSuggestions();
|
|
}}
|
|
}}
|
|
|
|
showSearchSuggestions(query) {{
|
|
// Implement search suggestions
|
|
// This would call an API to get suggestions
|
|
fetch(`/api/search/suggestions?q=${{encodeURIComponent(query)}}`)
|
|
.then(response => response.json())
|
|
.then(data => {{
|
|
this.displaySuggestions(data.suggestions);
|
|
}})
|
|
.catch(error => console.error('Error fetching suggestions:', error));
|
|
}}
|
|
|
|
displaySuggestions(suggestions) {{
|
|
// Display search suggestions dropdown
|
|
let suggestionsDropdown = document.getElementById('search-suggestions');
|
|
if (!suggestionsDropdown) {{
|
|
suggestionsDropdown = document.createElement('div');
|
|
suggestionsDropdown.id = 'search-suggestions';
|
|
suggestionsDropdown.className = 'search-suggestions dropdown-menu show';
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
searchInput.parentNode.appendChild(suggestionsDropdown);
|
|
}}
|
|
|
|
suggestionsDropdown.innerHTML = suggestions.map(suggestion => `
|
|
<a class="dropdown-item suggestion-item" href="#" data-value="${{suggestion.value}}">
|
|
<i class="fas ${{suggestion.icon || 'fa-search'}}"></i>
|
|
${{suggestion.text}}
|
|
<small class="text-muted">${{suggestion.type}}</small>
|
|
</a>
|
|
`).join('');
|
|
|
|
// Add click handlers
|
|
suggestionsDropdown.querySelectorAll('.suggestion-item').forEach(item => {{
|
|
item.addEventListener('click', (e) => {{
|
|
e.preventDefault();
|
|
document.getElementById('search-input').value = item.dataset.value;
|
|
this.performSearch();
|
|
this.hideSearchSuggestions();
|
|
}});
|
|
}});
|
|
}}
|
|
|
|
hideSearchSuggestions() {{
|
|
const suggestionsDropdown = document.getElementById('search-suggestions');
|
|
if (suggestionsDropdown) {{
|
|
suggestionsDropdown.remove();
|
|
}}
|
|
}}
|
|
|
|
async performSearch() {{
|
|
const query = document.getElementById('search-input').value;
|
|
const searchType = document.getElementById('search-type').value;
|
|
|
|
if (!query.trim()) {{
|
|
this.clearResults();
|
|
return;
|
|
}}
|
|
|
|
// Add to search history
|
|
this.addToSearchHistory(query);
|
|
|
|
// Show loading
|
|
this.showSearchLoading();
|
|
|
|
try {{
|
|
const searchParams = {{
|
|
query: query,
|
|
type: searchType,
|
|
filters: this.currentFilters,
|
|
sort_by: this.sortBy,
|
|
sort_order: this.sortOrder
|
|
}};
|
|
|
|
const response = await fetch('/api/search', {{
|
|
method: 'POST',
|
|
headers: {{
|
|
'Content-Type': 'application/json'
|
|
}},
|
|
body: JSON.stringify(searchParams)
|
|
}});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {{
|
|
this.searchResults = data.results;
|
|
this.displaySearchResults(data);
|
|
this.updateSearchInfo(query, data.total_results);
|
|
}} else {{
|
|
this.showSearchError(data.error);
|
|
}}
|
|
}} catch (error) {{
|
|
console.error('Search error:', error);
|
|
this.showSearchError('Search failed. Please try again.');
|
|
}}
|
|
}}
|
|
|
|
showSearchLoading() {{
|
|
const resultsContainer = document.getElementById('search-results');
|
|
if (resultsContainer) {{
|
|
resultsContainer.innerHTML = `
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Searching...</span>
|
|
</div>
|
|
<p class="mt-2 text-muted">Searching...</p>
|
|
</div>
|
|
`;
|
|
}}
|
|
}}
|
|
|
|
displaySearchResults(data) {{
|
|
const resultsContainer = document.getElementById('search-results');
|
|
if (!resultsContainer) return;
|
|
|
|
if (data.results.length === 0) {{
|
|
resultsContainer.innerHTML = `
|
|
<div class="text-center py-4">
|
|
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
|
<p class="text-muted">No results found. Try adjusting your search terms or filters.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}}
|
|
|
|
const resultsHTML = data.results.map(item => `
|
|
<div class="search-result-item card mb-2">
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-8">
|
|
<h6 class="card-title mb-1">${{item.name}}</h6>
|
|
<div class="text-muted small">
|
|
<span class="badge bg-secondary me-1">${{item.genre || 'Unknown'}}</span>
|
|
<span class="me-2">${{item.year || 'N/A'}}</span>
|
|
<span class="me-2">${{item.episodes || 0}} episodes</span>
|
|
${{item.rating ? `<span class="me-2"><i class="fas fa-star text-warning"></i> ${{item.rating}}</span>` : ''}}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4 text-end">
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="viewSeries('${{item.id}}')">
|
|
<i class="fas fa-eye"></i> View
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-success" onclick="downloadSeries('${{item.id}}')">
|
|
<i class="fas fa-download"></i> Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
resultsContainer.innerHTML = resultsHTML;
|
|
|
|
// Update pagination if needed
|
|
this.updatePagination(data);
|
|
}}
|
|
|
|
updateSearchInfo(query, totalResults) {{
|
|
const resultsCount = document.querySelector('.results-count');
|
|
const searchQuery = document.querySelector('.search-query');
|
|
|
|
if (resultsCount) {{
|
|
resultsCount.textContent = `${{totalResults}} result${{totalResults === 1 ? '' : 's'}}`;
|
|
}}
|
|
|
|
if (searchQuery) {{
|
|
searchQuery.textContent = query ? `for "${{query}}"` : '';
|
|
}}
|
|
}}
|
|
|
|
showSearchError(error) {{
|
|
const resultsContainer = document.getElementById('search-results');
|
|
if (resultsContainer) {{
|
|
resultsContainer.innerHTML = `
|
|
<div class="alert alert-danger">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
${{error}}
|
|
</div>
|
|
`;
|
|
}}
|
|
}}
|
|
|
|
clearResults() {{
|
|
const resultsContainer = document.getElementById('search-results');
|
|
if (resultsContainer) {{
|
|
resultsContainer.innerHTML = '';
|
|
}}
|
|
this.updateSearchInfo('', 0);
|
|
}}
|
|
|
|
clearSearch() {{
|
|
document.getElementById('search-input').value = '';
|
|
document.getElementById('search-type').value = 'all';
|
|
this.currentFilters = {{}};
|
|
this.clearResults();
|
|
this.updateActiveFilters();
|
|
this.hideSearchSuggestions();
|
|
}}
|
|
|
|
applyFilterPreset(presetName) {{
|
|
const preset = this.filterPresets[presetName];
|
|
if (preset) {{
|
|
this.currentFilters = {{ ...preset.filters }};
|
|
this.updateActiveFilters();
|
|
this.performSearch();
|
|
}}
|
|
}}
|
|
|
|
showAdvancedFilters() {{
|
|
const modal = document.getElementById('advanced-filters-modal');
|
|
if (modal) {{
|
|
const bsModal = new bootstrap.Modal(modal);
|
|
bsModal.show();
|
|
}}
|
|
}}
|
|
|
|
applyAdvancedFilters() {{
|
|
// Collect filter values from the modal
|
|
const filters = {{}};
|
|
|
|
// Genre filter
|
|
const genre = document.getElementById('filter-genre');
|
|
if (genre.selectedOptions.length > 0) {{
|
|
filters.genre = Array.from(genre.selectedOptions).map(o => o.value);
|
|
}}
|
|
|
|
// Year range
|
|
const yearFrom = document.getElementById('filter-year-from').value;
|
|
const yearTo = document.getElementById('filter-year-to').value;
|
|
if (yearFrom || yearTo) {{
|
|
filters.year_range = {{ from: yearFrom, to: yearTo }};
|
|
}}
|
|
|
|
// Rating range
|
|
const ratingMin = document.getElementById('filter-rating-min').value;
|
|
const ratingMax = document.getElementById('filter-rating-max').value;
|
|
if (ratingMin || ratingMax) {{
|
|
filters.rating_range = {{ min: ratingMin, max: ratingMax }};
|
|
}}
|
|
|
|
// Status
|
|
const status = document.getElementById('filter-status');
|
|
if (status.selectedOptions.length > 0) {{
|
|
filters.status = Array.from(status.selectedOptions).map(o => o.value);
|
|
}}
|
|
|
|
// Episode count range
|
|
const episodesMin = document.getElementById('filter-episodes-min').value;
|
|
const episodesMax = document.getElementById('filter-episodes-max').value;
|
|
if (episodesMin || episodesMax) {{
|
|
filters.episodes_range = {{ min: episodesMin, max: episodesMax }};
|
|
}}
|
|
|
|
// Date added
|
|
const dateAdded = document.getElementById('filter-date-added').value;
|
|
if (dateAdded) {{
|
|
if (dateAdded === 'custom') {{
|
|
const dateFrom = document.getElementById('filter-date-from').value;
|
|
const dateTo = document.getElementById('filter-date-to').value;
|
|
if (dateFrom || dateTo) {{
|
|
filters.date_range = {{ from: dateFrom, to: dateTo }};
|
|
}}
|
|
}} else {{
|
|
filters.date_added = dateAdded;
|
|
}}
|
|
}}
|
|
|
|
this.currentFilters = filters;
|
|
this.updateActiveFilters();
|
|
|
|
// Close modal and perform search
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('advanced-filters-modal'));
|
|
modal.hide();
|
|
|
|
this.performSearch();
|
|
}}
|
|
|
|
clearAllFilters() {{
|
|
this.currentFilters = {{}};
|
|
|
|
// Clear form fields
|
|
document.getElementById('filter-genre').selectedIndex = -1;
|
|
document.getElementById('filter-year-from').value = '';
|
|
document.getElementById('filter-year-to').value = '';
|
|
document.getElementById('filter-rating-min').value = '';
|
|
document.getElementById('filter-rating-max').value = '';
|
|
document.getElementById('filter-status').selectedIndex = -1;
|
|
document.getElementById('filter-episodes-min').value = '';
|
|
document.getElementById('filter-episodes-max').value = '';
|
|
document.getElementById('filter-date-added').value = '';
|
|
document.getElementById('filter-date-from').value = '';
|
|
document.getElementById('filter-date-to').value = '';
|
|
|
|
this.updateActiveFilters();
|
|
this.performSearch();
|
|
}}
|
|
|
|
updateActiveFilters() {{
|
|
const activeFiltersContainer = document.getElementById('active-filters');
|
|
if (!activeFiltersContainer) return;
|
|
|
|
const filterTags = [];
|
|
|
|
for (const [key, value] of Object.entries(this.currentFilters)) {{
|
|
let filterText = '';
|
|
|
|
switch(key) {{
|
|
case 'genre':
|
|
filterText = `Genre: ${{value.join(', ')}}`;
|
|
break;
|
|
case 'year_range':
|
|
filterText = `Year: ${{value.from || '?'}} - ${{value.to || '?'}}`;
|
|
break;
|
|
case 'rating_range':
|
|
filterText = `Rating: ${{value.min || '?'}} - ${{value.max || '?'}}`;
|
|
break;
|
|
case 'status':
|
|
filterText = `Status: ${{value.join(', ')}}`;
|
|
break;
|
|
case 'episodes_range':
|
|
filterText = `Episodes: ${{value.min || '?'}} - ${{value.max || '?'}}`;
|
|
break;
|
|
case 'date_added':
|
|
filterText = `Added: ${{value}}`;
|
|
break;
|
|
case 'date_range':
|
|
filterText = `Date: ${{value.from || '?'}} - ${{value.to || '?'}}`;
|
|
break;
|
|
}}
|
|
|
|
if (filterText) {{
|
|
filterTags.push(`
|
|
<span class="badge bg-primary me-1 mb-1">
|
|
${{filterText}}
|
|
<button type="button" class="btn-close btn-close-white ms-1"
|
|
onclick="advancedSearch.removeFilter('${{key}}')"
|
|
style="font-size: 0.7em;"></button>
|
|
</span>
|
|
`);
|
|
}}
|
|
}}
|
|
|
|
if (filterTags.length > 0) {{
|
|
activeFiltersContainer.innerHTML = `
|
|
<div class="d-flex align-items-center">
|
|
<small class="text-muted me-2">Active filters:</small>
|
|
${{filterTags.join('')}}
|
|
</div>
|
|
`;
|
|
}} else {{
|
|
activeFiltersContainer.innerHTML = '';
|
|
}}
|
|
}}
|
|
|
|
removeFilter(key) {{
|
|
delete this.currentFilters[key];
|
|
this.updateActiveFilters();
|
|
this.performSearch();
|
|
}}
|
|
|
|
handleSortChange(e) {{
|
|
this.sortBy = e.target.value;
|
|
this.performSearch();
|
|
}}
|
|
|
|
toggleSortOrder() {{
|
|
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
|
|
|
const btn = document.getElementById('sort-order-btn');
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (this.sortOrder === 'desc') {{
|
|
icon.className = 'fas fa-sort-alpha-up';
|
|
}} else {{
|
|
icon.className = 'fas fa-sort-alpha-down';
|
|
}}
|
|
|
|
this.performSearch();
|
|
}}
|
|
|
|
addToSearchHistory(query) {{
|
|
// Remove if already exists
|
|
this.searchHistory = this.searchHistory.filter(item => item.query !== query);
|
|
|
|
// Add to beginning
|
|
this.searchHistory.unshift({{
|
|
query: query,
|
|
timestamp: Date.now(),
|
|
filters: {{ ...this.currentFilters }}
|
|
}});
|
|
|
|
// Keep only last 10
|
|
if (this.searchHistory.length > 10) {{
|
|
this.searchHistory = this.searchHistory.slice(0, 10);
|
|
}}
|
|
|
|
this.saveSearchHistory();
|
|
this.updateSearchHistoryMenu();
|
|
}}
|
|
|
|
loadSearchHistory() {{
|
|
const stored = localStorage.getItem('aniworld_search_history');
|
|
if (stored) {{
|
|
try {{
|
|
this.searchHistory = JSON.parse(stored);
|
|
}} catch (error) {{
|
|
console.error('Error loading search history:', error);
|
|
this.searchHistory = [];
|
|
}}
|
|
}}
|
|
this.updateSearchHistoryMenu();
|
|
}}
|
|
|
|
saveSearchHistory() {{
|
|
localStorage.setItem('aniworld_search_history', JSON.stringify(this.searchHistory));
|
|
}}
|
|
|
|
updateSearchHistoryMenu() {{
|
|
const menu = document.getElementById('search-history-menu');
|
|
if (!menu) return;
|
|
|
|
if (this.searchHistory.length === 0) {{
|
|
menu.innerHTML = '<li><span class="dropdown-item-text text-muted">No recent searches</span></li>';
|
|
return;
|
|
}}
|
|
|
|
const historyItems = this.searchHistory.map(item => `
|
|
<li>
|
|
<a class="dropdown-item history-item" href="#"
|
|
data-query="${{item.query}}"
|
|
data-filters="${{JSON.stringify(item.filters)}}">
|
|
<div class="d-flex justify-content-between">
|
|
<span>${{item.query}}</span>
|
|
<small class="text-muted">${{this.formatTimestamp(item.timestamp)}}</small>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
`).join('');
|
|
|
|
menu.innerHTML = `
|
|
<li><h6 class="dropdown-header">Recent Searches</h6></li>
|
|
${{historyItems}}
|
|
<li><hr class="dropdown-divider"></li>
|
|
<li><a class="dropdown-item text-danger" href="#" onclick="advancedSearch.clearSearchHistory()">Clear History</a></li>
|
|
`;
|
|
|
|
// Add click handlers
|
|
menu.querySelectorAll('.history-item').forEach(item => {{
|
|
item.addEventListener('click', (e) => {{
|
|
e.preventDefault();
|
|
document.getElementById('search-input').value = item.dataset.query;
|
|
this.currentFilters = JSON.parse(item.dataset.filters);
|
|
this.updateActiveFilters();
|
|
this.performSearch();
|
|
}});
|
|
}});
|
|
}}
|
|
|
|
clearSearchHistory() {{
|
|
this.searchHistory = [];
|
|
this.saveSearchHistory();
|
|
this.updateSearchHistoryMenu();
|
|
}}
|
|
|
|
saveCurrentSearch() {{
|
|
const query = document.getElementById('search-input').value;
|
|
if (!query.trim()) return;
|
|
|
|
const name = prompt('Enter a name for this search:');
|
|
if (!name) return;
|
|
|
|
const searchId = Date.now().toString();
|
|
this.savedSearches[searchId] = {{
|
|
name: name,
|
|
query: query,
|
|
filters: {{ ...this.currentFilters }},
|
|
created: Date.now()
|
|
}};
|
|
|
|
this.saveSavedSearches();
|
|
this.populateSavedSearches();
|
|
|
|
this.showToast('Search saved successfully', 'success');
|
|
}}
|
|
|
|
loadSavedSearches() {{
|
|
const stored = localStorage.getItem('aniworld_saved_searches');
|
|
if (stored) {{
|
|
try {{
|
|
this.savedSearches = JSON.parse(stored);
|
|
}} catch (error) {{
|
|
console.error('Error loading saved searches:', error);
|
|
this.savedSearches = {{}};
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
saveSavedSearches() {{
|
|
localStorage.setItem('aniworld_saved_searches', JSON.stringify(this.savedSearches));
|
|
}}
|
|
|
|
populateSavedSearches() {{
|
|
const container = document.getElementById('saved-searches-list');
|
|
if (!container) return;
|
|
|
|
if (Object.keys(this.savedSearches).length === 0) {{
|
|
container.innerHTML = '<p class="text-muted">No saved searches</p>';
|
|
return;
|
|
}}
|
|
|
|
const searches = Object.entries(this.savedSearches).map(([id, search]) => `
|
|
<div class="saved-search-item border rounded p-2 mb-2">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<strong>${{search.name}}</strong>
|
|
<div class="text-muted small">${{search.query}}</div>
|
|
<div class="text-muted small">${{this.formatTimestamp(search.created)}}</div>
|
|
</div>
|
|
<div class="btn-group-vertical">
|
|
<button class="btn btn-sm btn-outline-primary"
|
|
onclick="advancedSearch.loadSavedSearch('${{id}}')">Load</button>
|
|
<button class="btn btn-sm btn-outline-danger"
|
|
onclick="advancedSearch.deleteSavedSearch('${{id}}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = searches;
|
|
}}
|
|
|
|
loadSavedSearch(searchId) {{
|
|
const search = this.savedSearches[searchId];
|
|
if (!search) return;
|
|
|
|
document.getElementById('search-input').value = search.query;
|
|
this.currentFilters = {{ ...search.filters }};
|
|
this.updateActiveFilters();
|
|
this.performSearch();
|
|
|
|
// Close modal
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('advanced-filters-modal'));
|
|
if (modal) {{
|
|
modal.hide();
|
|
}}
|
|
}}
|
|
|
|
deleteSavedSearch(searchId) {{
|
|
if (confirm('Are you sure you want to delete this saved search?')) {{
|
|
delete this.savedSearches[searchId];
|
|
this.saveSavedSearches();
|
|
this.populateSavedSearches();
|
|
}}
|
|
}}
|
|
|
|
populateFilterPresets() {{
|
|
const container = document.getElementById('filter-presets');
|
|
if (!container) return;
|
|
|
|
const presets = Object.entries(this.filterPresets).map(([key, preset]) => `
|
|
<button class="btn btn-outline-secondary mb-1" onclick="advancedSearch.applyFilterPreset('${{key}}')">
|
|
${{preset.name}}
|
|
</button>
|
|
`).join('');
|
|
|
|
container.innerHTML = presets;
|
|
}}
|
|
|
|
formatTimestamp(timestamp) {{
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
|
|
if (diff < 60000) return 'Just now';
|
|
if (diff < 3600000) return `${{Math.floor(diff / 60000)}}m ago`;
|
|
if (diff < 86400000) return `${{Math.floor(diff / 3600000)}}h ago`;
|
|
|
|
return date.toLocaleDateString();
|
|
}}
|
|
|
|
showToast(message, type = 'info') {{
|
|
// Create and show a toast notification
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast align-items-center text-white bg-${{type === 'error' ? 'danger' : type}}`;
|
|
toast.innerHTML = `
|
|
<div class="d-flex">
|
|
<div class="toast-body">${{message}}</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
`;
|
|
|
|
let toastContainer = document.querySelector('.toast-container');
|
|
if (!toastContainer) {{
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
|
document.body.appendChild(toastContainer);
|
|
}}
|
|
|
|
toastContainer.appendChild(toast);
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
bsToast.show();
|
|
|
|
toast.addEventListener('hidden.bs.toast', () => {{
|
|
if (toast.parentNode) {{
|
|
toastContainer.removeChild(toast);
|
|
}}
|
|
}});
|
|
}}
|
|
}}
|
|
|
|
// Initialize advanced search when DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', () => {{
|
|
window.advancedSearch = new AdvancedSearchManager();
|
|
}});
|
|
"""
|
|
|
|
def get_css(self):
|
|
"""Generate CSS for advanced search functionality."""
|
|
return """
|
|
/* Advanced Search Styles */
|
|
.advanced-search-bar {
|
|
background: var(--bs-light);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
border: 1px solid var(--bs-border-color);
|
|
}
|
|
|
|
.search-suggestions {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1000;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.suggestion-item {
|
|
display: flex;
|
|
justify-content: between;
|
|
align-items: center;
|
|
}
|
|
|
|
.suggestion-item i {
|
|
margin-right: 0.5rem;
|
|
width: 16px;
|
|
}
|
|
|
|
.quick-filters .filter-tag.active {
|
|
background-color: var(--bs-primary);
|
|
color: white;
|
|
}
|
|
|
|
.active-filters .badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.active-filters .btn-close {
|
|
--bs-btn-close-bg: none;
|
|
}
|
|
|
|
.search-result-item {
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.search-result-item:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.search-info .results-count {
|
|
font-weight: 600;
|
|
color: var(--bs-primary);
|
|
}
|
|
|
|
.sort-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
#advanced-filters-modal .modal-dialog {
|
|
max-width: 800px;
|
|
}
|
|
|
|
#advanced-filters-modal h6 {
|
|
color: var(--bs-primary);
|
|
border-bottom: 1px solid var(--bs-border-color);
|
|
padding-bottom: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.saved-search-item {
|
|
background: var(--bs-light);
|
|
}
|
|
|
|
.saved-search-item:hover {
|
|
background: var(--bs-secondary-bg);
|
|
}
|
|
|
|
/* Search loading animation */
|
|
.search-results .spinner-border {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
.advanced-search-bar .row {
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.advanced-search-bar .col-md-3,
|
|
.advanced-search-bar .col-md-6 {
|
|
flex: 1 1 100%;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.quick-filters {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.quick-filters > div {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search-result-item .row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.search-result-item .text-end {
|
|
text-align: start !important;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.sort-controls {
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
/* Dark theme support */
|
|
[data-bs-theme="dark"] .advanced-search-bar {
|
|
background: var(--bs-dark);
|
|
border-color: var(--bs-border-color-translucent);
|
|
}
|
|
|
|
[data-bs-theme="dark"] .search-suggestions {
|
|
background: var(--bs-dark);
|
|
border-color: var(--bs-border-color-translucent);
|
|
}
|
|
|
|
[data-bs-theme="dark"] .saved-search-item {
|
|
background: var(--bs-dark);
|
|
}
|
|
|
|
/* Animation for search results */
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.search-result-item {
|
|
animation: fadeInUp 0.3s ease-out;
|
|
}
|
|
|
|
/* Accessibility improvements */
|
|
.search-suggestions .dropdown-item:focus {
|
|
background-color: var(--bs-primary);
|
|
color: var(--bs-white);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.search-result-item,
|
|
.search-result-item:hover {
|
|
transition: none;
|
|
transform: none;
|
|
}
|
|
|
|
.search-result-item {
|
|
animation: none;
|
|
}
|
|
}
|
|
"""
|
|
|
|
def search_series(self, query: str, search_type: str = 'all', filters: Dict[str, Any] = None,
|
|
sort_by: str = 'name', sort_order: str = 'asc') -> Dict[str, Any]:
|
|
"""Search for series based on query and filters."""
|
|
try:
|
|
# This would implement actual search logic
|
|
# For now, return mock data
|
|
|
|
results = [
|
|
{
|
|
'id': '1',
|
|
'name': 'Attack on Titan',
|
|
'genre': 'Action',
|
|
'year': 2013,
|
|
'episodes': 75,
|
|
'rating': 9.0,
|
|
'status': 'completed'
|
|
},
|
|
{
|
|
'id': '2',
|
|
'name': 'Death Note',
|
|
'genre': 'Thriller',
|
|
'year': 2006,
|
|
'episodes': 37,
|
|
'rating': 9.1,
|
|
'status': 'completed'
|
|
}
|
|
]
|
|
|
|
# Apply search query filtering
|
|
if query:
|
|
results = [r for r in results if query.lower() in r['name'].lower()]
|
|
|
|
# Apply filters if provided
|
|
if filters:
|
|
if 'genre' in filters:
|
|
genres = filters['genre'] if isinstance(filters['genre'], list) else [filters['genre']]
|
|
results = [r for r in results if r.get('genre') in genres]
|
|
|
|
if 'status' in filters:
|
|
statuses = filters['status'] if isinstance(filters['status'], list) else [filters['status']]
|
|
results = [r for r in results if r.get('status') in statuses]
|
|
|
|
if 'year_range' in filters:
|
|
year_range = filters['year_range']
|
|
if year_range.get('from'):
|
|
results = [r for r in results if r.get('year', 0) >= int(year_range['from'])]
|
|
if year_range.get('to'):
|
|
results = [r for r in results if r.get('year', 0) <= int(year_range['to'])]
|
|
|
|
# Apply sorting
|
|
if sort_by in ['name', 'year', 'rating', 'episodes']:
|
|
reverse = (sort_order == 'desc')
|
|
results.sort(key=lambda x: x.get(sort_by, ''), reverse=reverse)
|
|
|
|
return {
|
|
'success': True,
|
|
'results': results,
|
|
'total_results': len(results),
|
|
'query': query,
|
|
'filters': filters or {}
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'results': [],
|
|
'total_results': 0
|
|
}
|
|
|
|
def get_search_suggestions(self, query: str) -> List[Dict[str, Any]]:
|
|
"""Get search suggestions for a query."""
|
|
suggestions = []
|
|
|
|
# Mock suggestions - replace with actual implementation
|
|
if 'attack' in query.lower():
|
|
suggestions.append({
|
|
'text': 'Attack on Titan',
|
|
'value': 'Attack on Titan',
|
|
'type': 'Series',
|
|
'icon': 'fa-film'
|
|
})
|
|
|
|
if 'action' in query.lower():
|
|
suggestions.append({
|
|
'text': 'Action',
|
|
'value': 'Action',
|
|
'type': 'Genre',
|
|
'icon': 'fa-tags'
|
|
})
|
|
|
|
return suggestions
|
|
|
|
|
|
# Create search API blueprint
|
|
search_bp = Blueprint('search', __name__, url_prefix='/api')
|
|
|
|
# Global search manager instance
|
|
search_manager = AdvancedSearchManager()
|
|
|
|
@search_bp.route('/search', methods=['POST'])
|
|
def search():
|
|
"""Perform search with query and filters."""
|
|
try:
|
|
data = request.get_json()
|
|
query = data.get('query', '')
|
|
search_type = data.get('type', 'all')
|
|
filters = data.get('filters', {})
|
|
sort_by = data.get('sort_by', 'name')
|
|
sort_order = data.get('sort_order', 'asc')
|
|
|
|
result = search_manager.search_series(query, search_type, filters, sort_by, sort_order)
|
|
return jsonify(result)
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'results': [],
|
|
'total_results': 0
|
|
}), 500
|
|
|
|
@search_bp.route('/search/suggestions', methods=['GET'])
|
|
def get_suggestions():
|
|
"""Get search suggestions."""
|
|
try:
|
|
query = request.args.get('q', '')
|
|
suggestions = search_manager.get_search_suggestions(query)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'suggestions': suggestions
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'suggestions': []
|
|
}), 500
|
|
|
|
|
|
# Export the search manager
|
|
advanced_search_manager = AdvancedSearchManager() |