feat: add custom query support for unresolved folder re-search

- Add SearchFolderRequest model for optional custom search query
- Update search endpoint to use custom query if provided
- Add search-again input field in UI for custom queries
- Increment search_attempts counter on re-search
This commit is contained in:
2026-06-06 23:31:25 +02:00
parent 486c5440f2
commit be7b210959
2 changed files with 95 additions and 12 deletions

View File

@@ -224,17 +224,25 @@ async def resolve_unresolved_folder(
) )
class SearchFolderRequest(BaseModel):
"""Request model for searching an unresolved folder with custom query."""
query: Optional[str] = Field(None, description="Custom search query override")
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse) @router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder( async def search_unresolved_folder(
folder_name: str, folder_name: str,
request: Optional[SearchFolderRequest] = None,
db=Depends(get_database_session), db=Depends(get_database_session),
) -> UnresolvedFolderResponse: ) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions. """Re-search for a specific unresolved folder to get fresh suggestions.
Performs a new search using the folder's title and caches the results. Performs a new search using the folder's title or a custom query.
Caches the results for subsequent display.
Args: Args:
folder_name: URL-encoded folder name to search for folder_name: URL-encoded folder name to search for
request: Optional SearchFolderRequest with custom query override
Returns: Returns:
UnresolvedFolderResponse with updated search suggestions UnresolvedFolderResponse with updated search suggestions
@@ -258,10 +266,13 @@ async def search_unresolved_folder(
detail=f"Folder already resolved: {folder_name}" detail=f"Folder already resolved: {folder_name}"
) )
# Use custom query if provided, otherwise fall back to folder title
search_query = request.query if request and request.query else folder.title
# Perform search # Perform search
series_app = get_series_app() series_app = get_series_app()
try: try:
results = await series_app.search(folder.title) results = await series_app.search(search_query)
search_result_json = json.dumps(results) if results else "[]" search_result_json = json.dumps(results) if results else "[]"
except Exception as e: except Exception as e:
logger.warning( logger.warning(
@@ -278,7 +289,7 @@ async def search_unresolved_folder(
folder_name=folder.folder_name, folder_name=folder.folder_name,
title=folder.title, title=folder.title,
year=folder.year, year=folder.year,
search_attempts=folder.search_attempts, search_attempts=folder.search_attempts + 1,
search_suggestions=results, search_suggestions=results,
) )

View File

@@ -238,6 +238,63 @@
opacity: 0.7; opacity: 0.7;
} }
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
.search-again-btn.searching {
pointer-events: none;
opacity: 0.7;
}
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
/* Empty state */ /* Empty state */
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -471,12 +528,17 @@
return res.json(); return res.json();
} }
async function reSearchFolder(folderName) { async function reSearchFolder(folderName, customQuery) {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName); const encodedName = encodeURIComponent(folderName);
const body = customQuery ? JSON.stringify({ query: customQuery }) : '{}';
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, { const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` } headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body
}); });
return res.json(); return res.json();
} }
@@ -497,15 +559,21 @@
? folder.search_suggestions.map(s => ` ? folder.search_suggestions.map(s => `
<div class="suggestion-item"> <div class="suggestion-item">
<i class="fas fa-link"></i> <i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a> <a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
</div> </div>
`).join('') `).join('')
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>'; : '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0) const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
? `<button class="search-again-btn" data-folder="${folder.folder_name}"> ? `<div class="search-again-row">
<i class="fas fa-search"></i> Search Again <input type="text" class="search-again-input"
</button>` placeholder="Custom search..."
value="${folder.title || ''}"
data-folder="${folder.folder_name}">
<button class="search-again-btn" data-folder="${folder.folder_name}">
<i class="fas fa-search"></i> Search Again
</button>
</div>`
: ''; : '';
return ` return `
@@ -650,25 +718,29 @@
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder; const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`); const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const searchInput = item.querySelector('.search-again-input');
const customQuery = searchInput ? searchInput.value.trim() : null;
btn.classList.add('searching'); btn.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try { try {
const result = await reSearchFolder(folder); const result = await reSearchFolder(folder, customQuery);
// Update suggestions in place // Update suggestions in place
const suggestionsEl = item.querySelector('.suggestion-list'); const suggestionsEl = item.querySelector('.suggestion-list');
if (result.search_suggestions && result.search_suggestions.length > 0) { if (result.search_suggestions && result.search_suggestions.length > 0) {
suggestionsEl.innerHTML = result.search_suggestions.map(s => ` suggestionsEl.innerHTML = result.search_suggestions.map(s => `
<div class="suggestion-item"> <div class="suggestion-item">
<i class="fas fa-link"></i> <i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a> <a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
</div> </div>
`).join(''); `).join('');
} else { } else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>'; suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
} }
btn.remove(); // Remove the search row since we now have suggestions (or none)
const searchRow = item.querySelector('.search-again-row');
if (searchRow) searchRow.remove();
} catch (err) { } catch (err) {
showToast('Search failed', 'error'); showToast('Search failed', 'error');
} finally { } finally {