Fix for Random Story Function

This commit is contained in:
Stefan Hardegger
2025-08-14 13:14:46 +02:00
parent 4357351ec8
commit 1d14d3d7aa
5 changed files with 128 additions and 5 deletions

4
backend/cookies_new.txt Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

@@ -10,6 +10,8 @@ import com.storycove.repository.TagRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.data.domain.Page;
@@ -25,12 +27,15 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Validated
@Transactional
public class StoryService {
private static final Logger logger = LoggerFactory.getLogger(StoryService.class);
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
private final ReadingPositionRepository readingPositionRepository;
@@ -653,12 +658,35 @@ public class StoryService {
/**
* Find a random story based on optional filters.
* Uses count + random offset approach for performance with large datasets.
* Supports text search and multiple tags.
* Uses Typesense for consistency with Library search functionality.
* Supports text search and multiple tags using the same logic as the Library view.
*/
@Transactional(readOnly = true)
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
// Use Typesense if available for consistency with Library search
if (typesenseService != null) {
try {
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags);
if (randomStoryId.isPresent()) {
return storyRepository.findById(randomStoryId.get());
}
return Optional.empty();
} catch (Exception e) {
// Fallback to database queries if Typesense fails
logger.warn("Typesense random story lookup failed, falling back to database queries", e);
}
}
// Fallback to original database-based implementation if Typesense is not available
return findRandomStoryFallback(searchQuery, tags);
}
/**
* Fallback method for random story selection using database queries.
* Used when Typesense is not available or fails.
*/
private Optional<Story> findRandomStoryFallback(String searchQuery, List<String> tags) {
// Clean up inputs
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
List<String> cleanTags = (tags != null) ? tags.stream()

View File

@@ -352,6 +352,87 @@ public class TypesenseService {
}
}
/**
* Get a random story using the same search logic as the Library view.
* This ensures consistency between Library search results and Random Story functionality.
* Uses offset-based randomization since Typesense v0.25.0 doesn't support _rand() sorting.
*/
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags) {
try {
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
// First, get the total count of matching stories
SearchParameters countParameters = new SearchParameters()
.q(normalizedQuery)
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
.perPage(0); // No results, just count
// Add tag filters if provided
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
countParameters.filterBy(tagFilter);
}
logger.debug("Getting random story with query: '{}', tags: {}", normalizedQuery, tags);
SearchResult countResult = typesenseClient.collections(STORIES_COLLECTION)
.documents()
.search(countParameters);
long totalHits = countResult.getFound();
if (totalHits == 0) {
logger.debug("No stories found matching filters");
return Optional.empty();
}
// Generate random offset within the total hits
long randomOffset = (long) (Math.random() * totalHits);
logger.debug("Total hits: {}, using random offset: {}", totalHits, randomOffset);
// Now get the actual story at that offset
SearchParameters storyParameters = new SearchParameters()
.q(normalizedQuery)
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
.page((int) (randomOffset / 250) + 1) // Calculate page (Typesense uses 1-based pages)
.perPage(250) // Get multiple results to account for offset within page
.sortBy("_text_match:desc,createdAt:desc"); // Use standard sorting
// Add same tag filters
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
storyParameters.filterBy(tagFilter);
}
SearchResult storyResult = typesenseClient.collections(STORIES_COLLECTION)
.documents()
.search(storyParameters);
if (storyResult.getHits().isEmpty()) {
logger.debug("No stories found in random offset query");
return Optional.empty();
}
// Calculate which hit to select from the page
int indexInPage = (int) (randomOffset % 250);
indexInPage = Math.min(indexInPage, storyResult.getHits().size() - 1);
SearchResultHit hit = storyResult.getHits().get(indexInPage);
String storyId = (String) hit.getDocument().get("id");
logger.debug("Found random story ID: {} at offset {} (page {}, index {})",
storyId, randomOffset, storyParameters.getPage(), indexInPage);
return Optional.of(UUID.fromString(storyId));
} catch (Exception e) {
logger.error("Failed to get random story with query: '{}', tags: {}", searchQuery, tags, e);
return Optional.empty();
}
}
private Map<String, Object> createStoryDocument(Story story) {
Map<String, Object> document = new HashMap<>();
document.put("id", story.getId().toString());

View File

@@ -169,8 +169,8 @@ export default function LibraryPage() {
return;
}
// Navigate to the random story's reading page
router.push(`/stories/${randomStory.id}/read`);
// Navigate to the random story's detail page
router.push(`/stories/${randomStory.id}`);
} catch (error) {
console.error('Failed to get random story:', error);

View File

@@ -198,7 +198,17 @@ export const storyApi = {
tags?: string[];
}): Promise<Story | null> => {
try {
const response = await api.get('/stories/random', { params: filters });
// Create URLSearchParams to properly handle array parameters like tags
const searchParams = new URLSearchParams();
if (filters?.searchQuery) {
searchParams.append('searchQuery', filters.searchQuery);
}
if (filters?.tags && filters.tags.length > 0) {
filters.tags.forEach(tag => searchParams.append('tags', tag));
}
const response = await api.get(`/stories/random?${searchParams.toString()}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 204) {