Aniworld/src/server/application/services/search_service.py

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()