random story selector

This commit is contained in:
Stefan Hardegger
2025-08-13 14:48:40 +02:00
parent 142d8328c2
commit 4ab03953ae
5 changed files with 280 additions and 1 deletions

View File

@@ -25,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -84,6 +85,24 @@ public class StoryController {
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/random")
public ResponseEntity<StorySummaryDto> getRandomStory(
@RequestParam(required = false) String searchQuery,
@RequestParam(required = false) List<String> tags) {
logger.info("Getting random story with filters - searchQuery: {}, tags: {}",
searchQuery, tags);
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags);
if (randomStory.isPresent()) {
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
return ResponseEntity.ok(storyDto);
} else {
return ResponseEntity.noContent().build(); // 204 No Content when no stories match filters
}
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) { public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
Story story = storyService.findById(id); Story story = storyService.findById(id);

View File

@@ -119,4 +119,126 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)") @Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName); List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
/**
* Count all stories for random selection (no filters)
*/
@Query(value = "SELECT COUNT(*) FROM stories", nativeQuery = true)
long countAllStories();
/**
* Count stories matching tag name filter for random selection
*/
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
"JOIN story_tags st ON s.id = st.story_id " +
"JOIN tags t ON st.tag_id = t.id " +
"WHERE UPPER(t.name) = UPPER(?1)",
nativeQuery = true)
long countStoriesByTagName(String tagName);
/**
* Find a random story using offset (no filters)
*/
@Query(value = "SELECT s.* FROM stories s ORDER BY s.id OFFSET ?1 LIMIT 1", nativeQuery = true)
Optional<Story> findRandomStory(long offset);
/**
* Find a random story matching tag name filter using offset
*/
@Query(value = "SELECT s.* FROM stories s " +
"JOIN story_tags st ON s.id = st.story_id " +
"JOIN tags t ON st.tag_id = t.id " +
"WHERE UPPER(t.name) = UPPER(?1) " +
"ORDER BY s.id OFFSET ?2 LIMIT 1",
nativeQuery = true)
Optional<Story> findRandomStoryByTagName(String tagName, long offset);
/**
* Count stories matching multiple tags (ALL tags must be present)
*/
@Query(value = "SELECT COUNT(*) FROM (" +
" SELECT DISTINCT s.id FROM stories s " +
" JOIN story_tags st ON s.id = st.story_id " +
" JOIN tags t ON st.tag_id = t.id " +
" WHERE UPPER(t.name) IN (?1) " +
" GROUP BY s.id " +
" HAVING COUNT(DISTINCT t.name) = ?2" +
") as matched_stories",
nativeQuery = true)
long countStoriesByMultipleTags(List<String> upperCaseTagNames, int tagCount);
/**
* Find random story matching multiple tags (ALL tags must be present)
*/
@Query(value = "SELECT s.* FROM stories s " +
"JOIN story_tags st ON s.id = st.story_id " +
"JOIN tags t ON st.tag_id = t.id " +
"WHERE UPPER(t.name) IN (?1) " +
"GROUP BY s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, s.created_at, s.updated_at " +
"HAVING COUNT(DISTINCT t.name) = ?2 " +
"ORDER BY s.id OFFSET ?3 LIMIT 1",
nativeQuery = true)
Optional<Story> findRandomStoryByMultipleTags(List<String> upperCaseTagNames, int tagCount, long offset);
/**
* Count stories matching text search (title, author, tags)
*/
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
"LEFT JOIN authors a ON s.author_id = a.id " +
"LEFT JOIN story_tags st ON s.id = st.story_id " +
"LEFT JOIN tags t ON st.tag_id = t.id " +
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1))",
nativeQuery = true)
long countStoriesByTextSearch(String searchPattern);
/**
* Find random story matching text search (title, author, tags)
*/
@Query(value = "SELECT DISTINCT s.* FROM stories s " +
"LEFT JOIN authors a ON s.author_id = a.id " +
"LEFT JOIN story_tags st ON s.id = st.story_id " +
"LEFT JOIN tags t ON st.tag_id = t.id " +
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
"ORDER BY s.id OFFSET ?2 LIMIT 1",
nativeQuery = true)
Optional<Story> findRandomStoryByTextSearch(String searchPattern, long offset);
/**
* Count stories matching both text search AND tags
*/
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
"LEFT JOIN authors a ON s.author_id = a.id " +
"LEFT JOIN story_tags st ON s.id = st.story_id " +
"LEFT JOIN tags t ON st.tag_id = t.id " +
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
"AND s.id IN (" +
" SELECT s2.id FROM stories s2 " +
" JOIN story_tags st2 ON s2.id = st2.story_id " +
" JOIN tags t2 ON st2.tag_id = t2.id " +
" WHERE UPPER(t2.name) IN (?2) " +
" GROUP BY s2.id " +
" HAVING COUNT(DISTINCT t2.name) = ?3" +
")",
nativeQuery = true)
long countStoriesByTextSearchAndTags(String searchPattern, List<String> upperCaseTagNames, int tagCount);
/**
* Find random story matching both text search AND tags
*/
@Query(value = "SELECT DISTINCT s.* FROM stories s " +
"LEFT JOIN authors a ON s.author_id = a.id " +
"LEFT JOIN story_tags st ON s.id = st.story_id " +
"LEFT JOIN tags t ON st.tag_id = t.id " +
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
"AND s.id IN (" +
" SELECT s2.id FROM stories s2 " +
" JOIN story_tags st2 ON s2.id = st2.story_id " +
" JOIN tags t2 ON st2.tag_id = t2.id " +
" WHERE UPPER(t2.name) IN (?2) " +
" GROUP BY s2.id " +
" HAVING COUNT(DISTINCT t2.name) = ?3" +
") " +
"ORDER BY s.id OFFSET ?4 LIMIT 1",
nativeQuery = true)
Optional<Story> findRandomStoryByTextSearchAndTags(String searchPattern, List<String> upperCaseTagNames, int tagCount, long offset);
} }

View File

@@ -650,4 +650,78 @@ public class StoryService {
} }
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim()); return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
} }
/**
* Find a random story based on optional filters.
* Uses count + random offset approach for performance with large datasets.
* Supports text search and multiple tags.
*/
@Transactional(readOnly = true)
public Optional<Story> findRandomStory(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()
.filter(tag -> tag != null && !tag.trim().isEmpty())
.map(String::trim)
.collect(Collectors.toList()) : List.of();
long totalCount = 0;
Optional<Story> randomStory = Optional.empty();
if (cleanSearchQuery != null && !cleanTags.isEmpty()) {
// Both search query and tags
String searchPattern = "%" + cleanSearchQuery + "%";
List<String> upperCaseTags = cleanTags.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
totalCount = storyRepository.countStoriesByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size());
if (totalCount > 0) {
long randomOffset = (long) (Math.random() * totalCount);
randomStory = storyRepository.findRandomStoryByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size(), randomOffset);
}
} else if (cleanSearchQuery != null) {
// Only search query
String searchPattern = "%" + cleanSearchQuery + "%";
totalCount = storyRepository.countStoriesByTextSearch(searchPattern);
if (totalCount > 0) {
long randomOffset = (long) (Math.random() * totalCount);
randomStory = storyRepository.findRandomStoryByTextSearch(searchPattern, randomOffset);
}
} else if (!cleanTags.isEmpty()) {
// Only tags
if (cleanTags.size() == 1) {
// Single tag - use optimized single tag query
totalCount = storyRepository.countStoriesByTagName(cleanTags.get(0));
if (totalCount > 0) {
long randomOffset = (long) (Math.random() * totalCount);
randomStory = storyRepository.findRandomStoryByTagName(cleanTags.get(0), randomOffset);
}
} else {
// Multiple tags
List<String> upperCaseTags = cleanTags.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
totalCount = storyRepository.countStoriesByMultipleTags(upperCaseTags, cleanTags.size());
if (totalCount > 0) {
long randomOffset = (long) (Math.random() * totalCount);
randomStory = storyRepository.findRandomStoryByMultipleTags(upperCaseTags, cleanTags.size(), randomOffset);
}
}
} else {
// No filters - get random from all stories
totalCount = storyRepository.countAllStories();
if (totalCount > 0) {
long randomOffset = (long) (Math.random() * totalCount);
randomStory = storyRepository.findRandomStory(randomOffset);
}
}
return randomStory;
}
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { searchApi } from '../../lib/api'; import { useRouter } from 'next/navigation';
import { searchApi, storyApi } from '../../lib/api';
import { Story, Tag, FacetCount } from '../../types/api'; import { Story, Tag, FacetCount } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input'; import { Input } from '../../components/ui/Input';
@@ -14,10 +15,12 @@ type ViewMode = 'grid' | 'list';
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead'; type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
export default function LibraryPage() { export default function LibraryPage() {
const router = useRouter();
const [stories, setStories] = useState<Story[]>([]); const [stories, setStories] = useState<Story[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [randomLoading, setRandomLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
@@ -139,6 +142,44 @@ export default function LibraryPage() {
setRefreshTrigger(prev => prev + 1); setRefreshTrigger(prev => prev + 1);
}; };
const handleRandomStory = async () => {
try {
setRandomLoading(true);
// Build filter parameters based on current UI state
const filters: Record<string, any> = {};
// Include search query if present
if (searchQuery && searchQuery.trim() !== '' && searchQuery !== '*') {
filters.searchQuery = searchQuery.trim();
}
// Include all selected tags
if (selectedTags.length > 0) {
filters.tags = selectedTags;
}
console.log('Getting random story with filters:', filters);
const randomStory = await storyApi.getRandomStory(filters);
if (!randomStory) {
// No stories match the current filters
alert('No stories match your current filters. Try clearing some filters or adding more stories to your library.');
return;
}
// Navigate to the random story's reading page
router.push(`/stories/${randomStory.id}/read`);
} catch (error) {
console.error('Failed to get random story:', error);
alert('Failed to get a random story. Please try again.');
} finally {
setRandomLoading(false);
}
};
if (loading) { if (loading) {
return ( return (
<AppLayout> <AppLayout>
@@ -163,6 +204,13 @@ export default function LibraryPage() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
onClick={handleRandomStory}
disabled={randomLoading || totalElements === 0}
variant="secondary"
>
{randomLoading ? '🎲 ...' : '🎲 Random Story'}
</Button>
<Button href="/import"> <Button href="/import">
Add New Story Add New Story
</Button> </Button>

View File

@@ -192,6 +192,22 @@ export const storyApi = {
}); });
return response.data; return response.data;
}, },
getRandomStory: async (filters?: {
searchQuery?: string;
tags?: string[];
}): Promise<Story | null> => {
try {
const response = await api.get('/stories/random', { params: filters });
return response.data;
} catch (error: any) {
if (error.response?.status === 204) {
// No content - no stories match filters
return null;
}
throw error;
}
},
}; };
// Author endpoints // Author endpoints