Series to Collection Function

This commit is contained in:
Stefan Hardegger
2026-07-20 08:42:00 +02:00
parent 9161ec6baa
commit 36aad0f47e
9 changed files with 221 additions and 16 deletions

View File

@@ -5,6 +5,7 @@ import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory; import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story; import com.storycove.entity.Story;
import com.storycove.entity.Tag; import com.storycove.entity.Tag;
import com.storycove.service.AudioCoveService;
import com.storycove.service.CollectionService; import com.storycove.service.CollectionService;
import com.storycove.service.EPUBExportService; import com.storycove.service.EPUBExportService;
import com.storycove.service.ImageService; import com.storycove.service.ImageService;
@@ -29,16 +30,19 @@ public class CollectionController {
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class); private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
private final CollectionService collectionService; private final CollectionService collectionService;
private final AudioCoveService audioCoveService;
private final ImageService imageService; private final ImageService imageService;
private final ReadingTimeService readingTimeService; private final ReadingTimeService readingTimeService;
private final EPUBExportService epubExportService; private final EPUBExportService epubExportService;
@Autowired @Autowired
public CollectionController(CollectionService collectionService, public CollectionController(CollectionService collectionService,
AudioCoveService audioCoveService,
ImageService imageService, ImageService imageService,
ReadingTimeService readingTimeService, ReadingTimeService readingTimeService,
EPUBExportService epubExportService) { EPUBExportService epubExportService) {
this.collectionService = collectionService; this.collectionService = collectionService;
this.audioCoveService = audioCoveService;
this.imageService = imageService; this.imageService = imageService;
this.readingTimeService = readingTimeService; this.readingTimeService = readingTimeService;
this.epubExportService = epubExportService; this.epubExportService = epubExportService;
@@ -281,6 +285,25 @@ public class CollectionController {
return ResponseEntity.ok(Map.of("message", "Cover removed successfully")); return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
} }
/**
* POST /api/collections/{id}/send-to-audiocove - Send combined collection content to AudioCove
*/
@PostMapping("/{id}/send-to-audiocove")
public ResponseEntity<String> sendCollectionToAudioCove(@PathVariable UUID id) {
try {
String response = audioCoveService.ingestCollection(id);
return ResponseEntity.ok(response);
} catch (IllegalStateException e) {
if (e.getMessage().contains("not configured")) {
return ResponseEntity.status(503).body(e.getMessage());
}
return ResponseEntity.badRequest().body(e.getMessage());
} catch (org.springframework.web.client.RestClientException e) {
logger.error("AudioCove gateway error for collection {}: {}", id, e.getMessage());
return ResponseEntity.status(502).body("AudioCove gateway error: " + e.getMessage());
}
}
/** /**
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense * POST /api/collections/reindex-typesense - Reindex all collections in Typesense
*/ */

View File

@@ -1,7 +1,9 @@
package com.storycove.controller; package com.storycove.controller;
import com.storycove.dto.SeriesDto; import com.storycove.dto.SeriesDto;
import com.storycove.entity.Collection;
import com.storycove.entity.Series; import com.storycove.entity.Series;
import com.storycove.service.CollectionService;
import com.storycove.service.SeriesService; import com.storycove.service.SeriesService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -22,9 +24,11 @@ import java.util.stream.Collectors;
public class SeriesController { public class SeriesController {
private final SeriesService seriesService; private final SeriesService seriesService;
private final CollectionService collectionService;
public SeriesController(SeriesService seriesService) { public SeriesController(SeriesService seriesService, CollectionService collectionService) {
this.seriesService = seriesService; this.seriesService = seriesService;
this.collectionService = collectionService;
} }
@GetMapping @GetMapping
@@ -128,6 +132,15 @@ public class SeriesController {
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
@PostMapping("/{id}/create-collection")
public ResponseEntity<Map<String, String>> createCollectionFromSeries(@PathVariable UUID id) {
Collection collection = collectionService.createCollectionFromSeries(id);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"collectionId", collection.getId().toString(),
"name", collection.getName()
));
}
private void updateSeriesFromRequest(Series series, Object request) { private void updateSeriesFromRequest(Series series, Object request) {
if (request instanceof CreateSeriesRequest createReq) { if (request instanceof CreateSeriesRequest createReq) {
series.setName(createReq.getName()); series.setName(createReq.getName());

View File

@@ -2,6 +2,8 @@ package com.storycove.service;
import com.storycove.config.AudioCoveProperties; import com.storycove.config.AudioCoveProperties;
import com.storycove.dto.AudioCoveIngestRequest; import com.storycove.dto.AudioCoveIngestRequest;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story; import com.storycove.entity.Story;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -15,6 +17,7 @@ import org.springframework.web.client.RestClientException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -24,14 +27,17 @@ public class AudioCoveService {
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class); private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
private final StoryService storyService; private final StoryService storyService;
private final CollectionService collectionService;
private final AudioCoveProperties audioCoveProperties; private final AudioCoveProperties audioCoveProperties;
private final String publicUrl; private final String publicUrl;
private final RestClient restClient; private final RestClient restClient;
public AudioCoveService(StoryService storyService, public AudioCoveService(StoryService storyService,
CollectionService collectionService,
AudioCoveProperties audioCoveProperties, AudioCoveProperties audioCoveProperties,
@Value("${storycove.app.public-url}") String publicUrl) { @Value("${storycove.app.public-url}") String publicUrl) {
this.storyService = storyService; this.storyService = storyService;
this.collectionService = collectionService;
this.audioCoveProperties = audioCoveProperties; this.audioCoveProperties = audioCoveProperties;
this.publicUrl = publicUrl; this.publicUrl = publicUrl;
this.restClient = RestClient.create(); this.restClient = RestClient.create();
@@ -65,6 +71,70 @@ public class AudioCoveService {
} }
} }
@Transactional(readOnly = true)
public String ingestCollection(UUID collectionId) {
if (!audioCoveProperties.isConfigured()) {
throw new IllegalStateException("AudioCove URL is not configured. Set the AUDIOCOVE_URL environment variable.");
}
Collection collection = collectionService.findById(collectionId);
List<CollectionStory> collectionStories = collection.getCollectionStories().stream()
.sorted(Comparator.comparingInt(CollectionStory::getPosition))
.toList();
if (collectionStories.isEmpty()) {
throw new IllegalStateException("Collection has no stories to send to AudioCove.");
}
StringBuilder combinedContent = new StringBuilder();
for (int i = 0; i < collectionStories.size(); i++) {
Story story = collectionStories.get(i).getStory();
String chapterTitle = story.getTitle() + " - Part " + (i + 1) + " of Collection " + collection.getName();
combinedContent.append("<h1>").append(chapterTitle).append("</h1>\n");
if (story.getContentHtml() != null) {
combinedContent.append(story.getContentHtml());
}
combinedContent.append("\n");
}
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
request.setStoryId("collection:" + collectionId);
request.setTitle(collection.getName());
request.setContentHtml(combinedContent.toString());
request.setContentHash(computeContentHash(combinedContent.toString()));
request.setCollection(collection.getName());
List<String> allTags = collectionStories.stream()
.flatMap(cs -> cs.getStory().getTags().stream())
.map(tag -> tag.getName())
.distinct()
.toList();
request.setTags(allTags);
if (collection.getCoverImagePath() != null && !collection.getCoverImagePath().isBlank()) {
request.setCoverImage(publicUrl + "/api/files/images/" + collection.getCoverImagePath());
}
logger.info("Sending collection '{}' ({}) to AudioCove with {} stories",
collection.getName(), collectionId, collectionStories.size());
try {
String response = restClient.post()
.uri(audioCoveProperties.getUrl() + "/api/ingest")
.header("X-API-Key", audioCoveProperties.getApiKey())
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class);
logger.info("AudioCove ingest successful for collection '{}'", collection.getName());
return response;
} catch (RestClientException e) {
logger.error("AudioCove ingest failed for collection '{}': {}", collection.getName(), e.getMessage());
throw e;
}
}
private AudioCoveIngestRequest buildIngestRequest(Story story) { private AudioCoveIngestRequest buildIngestRequest(Story story) {
AudioCoveIngestRequest request = new AudioCoveIngestRequest(); AudioCoveIngestRequest request = new AudioCoveIngestRequest();

View File

@@ -10,6 +10,7 @@ import com.storycove.entity.Story;
import com.storycove.entity.Tag; import com.storycove.entity.Tag;
import com.storycove.repository.CollectionRepository; import com.storycove.repository.CollectionRepository;
import com.storycove.repository.CollectionStoryRepository; import com.storycove.repository.CollectionStoryRepository;
import com.storycove.repository.SeriesRepository;
import com.storycove.repository.StoryRepository; import com.storycove.repository.StoryRepository;
import com.storycove.repository.TagRepository; import com.storycove.repository.TagRepository;
import com.storycove.service.exception.ResourceNotFoundException; import com.storycove.service.exception.ResourceNotFoundException;
@@ -32,6 +33,7 @@ public class CollectionService {
private final CollectionStoryRepository collectionStoryRepository; private final CollectionStoryRepository collectionStoryRepository;
private final StoryRepository storyRepository; private final StoryRepository storyRepository;
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final SeriesRepository seriesRepository;
private final SearchServiceAdapter searchServiceAdapter; private final SearchServiceAdapter searchServiceAdapter;
private final ReadingTimeService readingTimeService; private final ReadingTimeService readingTimeService;
@@ -40,12 +42,14 @@ public class CollectionService {
CollectionStoryRepository collectionStoryRepository, CollectionStoryRepository collectionStoryRepository,
StoryRepository storyRepository, StoryRepository storyRepository,
TagRepository tagRepository, TagRepository tagRepository,
SeriesRepository seriesRepository,
SearchServiceAdapter searchServiceAdapter, SearchServiceAdapter searchServiceAdapter,
ReadingTimeService readingTimeService) { ReadingTimeService readingTimeService) {
this.collectionRepository = collectionRepository; this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository; this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository; this.storyRepository = storyRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.seriesRepository = seriesRepository;
this.searchServiceAdapter = searchServiceAdapter; this.searchServiceAdapter = searchServiceAdapter;
this.readingTimeService = readingTimeService; this.readingTimeService = readingTimeService;
} }
@@ -416,6 +420,26 @@ public class CollectionService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Create a collection from all stories in a series, ordered by volume
*/
public Collection createCollectionFromSeries(UUID seriesId) {
com.storycove.entity.Series series = seriesRepository.findById(seriesId)
.orElseThrow(() -> new ResourceNotFoundException("Series not found with id: " + seriesId));
List<UUID> storyIds = series.getStories().stream()
.sorted(java.util.Comparator.comparingInt(s -> s.getVolume() != null ? s.getVolume() : Integer.MAX_VALUE))
.map(Story::getId)
.collect(Collectors.toList());
if (storyIds.isEmpty()) {
throw new IllegalStateException("Cannot create a collection from a series with no stories.");
}
logger.info("Creating collection from series '{}' with {} stories", series.getName(), storyIds.size());
return createCollection(series.getName(), series.getDescription(), null, storyIds);
}
/** /**
* Get all collections for indexing (used by SearchServiceAdapter) * Get all collections for indexing (used by SearchServiceAdapter)
*/ */

View File

@@ -279,7 +279,11 @@ public class DatabaseManagementService {
Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql"); Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql");
try { try {
// Build pg_dump command // Build pg_dump command.
// --no-owner / --no-acl: omit ownership and privilege SQL that would fail
// when restoring to a different environment or under a different role.
// --create is intentionally omitted: it generates ALTER DATABASE / \connect
// commands for the source database name which break cross-environment restores.
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"pg_dump", "pg_dump",
"--host=" + dbHost, "--host=" + dbHost,
@@ -290,7 +294,8 @@ public class DatabaseManagementService {
"--verbose", "--verbose",
"--clean", "--clean",
"--if-exists", "--if-exists",
"--create", "--no-owner",
"--no-acl",
"--file=" + tempBackupFile.toString() "--file=" + tempBackupFile.toString()
); );
@@ -368,12 +373,18 @@ public class DatabaseManagementService {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
// Skip DROP DATABASE and CREATE DATABASE commands - we're already connected to the DB // Skip database-level commands that were generated by older backups using
// Also skip database connection commands as we're already connected // pg_dump --create and that would fail when restoring to a different environment.
if (line.trim().startsWith("DROP DATABASE") || // New backups no longer use --create so these lines won't appear, but we keep
line.trim().startsWith("CREATE DATABASE") || // the filters for backward compatibility with previously created backups.
line.trim().startsWith("\\connect")) { String trimmed = line.trim();
System.err.println("Skipping incompatible command: " + line.substring(0, Math.min(50, line.length()))); if (trimmed.startsWith("DROP DATABASE") ||
trimmed.startsWith("CREATE DATABASE") ||
trimmed.startsWith("\\connect") ||
trimmed.startsWith("\\c ") || // short-form \connect alias
trimmed.startsWith("ALTER DATABASE") ||
trimmed.startsWith("COMMENT ON DATABASE")) {
System.err.println("Skipping database-level command: " + line.substring(0, Math.min(80, line.length())));
continue; continue;
} }
writer.write(line); writer.write(line);
@@ -383,7 +394,10 @@ public class DatabaseManagementService {
System.err.println("Starting PostgreSQL restore using psql..."); System.err.println("Starting PostgreSQL restore using psql...");
// Build psql command to restore the backup // Build psql command to restore the backup.
// --single-transaction wraps the entire restore in one atomic transaction:
// if any statement fails (e.g. a COPY for story_tags), the whole restore rolls
// back and an error is returned rather than leaving the DB in a partial state.
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"psql", "psql",
"--host=" + dbHost, "--host=" + dbHost,
@@ -392,6 +406,7 @@ public class DatabaseManagementService {
"--dbname=" + dbName, "--dbname=" + dbName,
"--no-password", "--no-password",
"--echo-errors", "--echo-errors",
"--single-transaction",
"--file=" + tempBackupFile.toString() "--file=" + tempBackupFile.toString()
); );

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { storyApi, seriesApi, audioCoveApi, getImageUrl } from '../../../../lib/api'; import { storyApi, seriesApi, audioCoveApi, collectionApi, getImageUrl } from '../../../../lib/api';
import { Story, Collection } from '../../../../types/api'; import { Story, Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout'; import AppLayout from '../../../../components/layout/AppLayout';
import Button from '../../../../components/ui/Button'; import Button from '../../../../components/ui/Button';
@@ -26,6 +26,7 @@ export default function StoryDetailPage() {
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false); const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
const [audioCovedSuccess, setAudioCovedSuccess] = useState(false); const [audioCovedSuccess, setAudioCovedSuccess] = useState(false);
const [isCreatingCollection, setIsCreatingCollection] = useState(false);
useEffect(() => { useEffect(() => {
const loadStoryData = async () => { const loadStoryData = async () => {
@@ -133,6 +134,20 @@ export default function StoryDetailPage() {
} }
}; };
const handleCreateCollectionFromSeries = async () => {
if (!story?.seriesId || isCreatingCollection) return;
setIsCreatingCollection(true);
try {
const result = await seriesApi.createCollectionFromSeries(story.seriesId);
router.push(`/collections/${result.collectionId}`);
} catch (error: any) {
const message = error?.response?.data || 'Failed to create collection from series.';
alert(message);
} finally {
setIsCreatingCollection(false);
}
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@@ -319,6 +334,16 @@ export default function StoryDetailPage() {
</div> </div>
</div> </div>
)} )}
<div className="mt-4">
<Button
onClick={handleCreateCollectionFromSeries}
variant="ghost"
size="sm"
disabled={isCreatingCollection}
>
{isCreatingCollection ? 'Creating...' : '📚 Create Collection from Series'}
</Button>
</div>
</div> </div>
)} )}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Collection } from '../../types/api'; import { Collection } from '../../types/api';
import { collectionApi, getImageUrl } from '../../lib/api'; import { collectionApi, audioCoveApi, getImageUrl } from '../../lib/api';
import Button from '../ui/Button'; import Button from '../ui/Button';
import StoryReorderList from './StoryReorderList'; import StoryReorderList from './StoryReorderList';
import AddToCollectionModal from './AddToCollectionModal'; import AddToCollectionModal from './AddToCollectionModal';
@@ -29,6 +29,8 @@ export default function CollectionDetailView({
const [editRating, setEditRating] = useState(collection.rating || ''); const [editRating, setEditRating] = useState(collection.rating || '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
const [audioCoveSuccess, setAudioCoveSuccess] = useState(false);
const formatReadingTime = (minutes: number): string => { const formatReadingTime = (minutes: number): string => {
if (minutes < 60) { if (minutes < 60) {
@@ -118,6 +120,21 @@ export default function CollectionDetailView({
} }
}; };
const handleSendToAudioCove = async () => {
if (isSendingToAudioCove) return;
setIsSendingToAudioCove(true);
try {
await audioCoveApi.sendCollectionToAudioCove(collection.id);
setAudioCoveSuccess(true);
setTimeout(() => setAudioCoveSuccess(false), 3000);
} catch (error: any) {
const message = error?.response?.data || 'Failed to send to AudioCove. Please try again.';
alert(message);
} finally {
setIsSendingToAudioCove(false);
}
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header Section */} {/* Header Section */}
@@ -305,6 +322,14 @@ export default function CollectionDetailView({
> >
{actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'} {actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'}
</Button> </Button>
<Button
variant="ghost"
onClick={handleSendToAudioCove}
disabled={isSendingToAudioCove || audioCoveSuccess || collection.storyCount === 0}
className="flex-shrink-0"
>
{isSendingToAudioCove ? '🎧 Sending...' : audioCoveSuccess ? '✅ Sent!' : '🎧 Send to Audiocove'}
</Button>
</div> </div>
{/* Tags */} {/* Tags */}

View File

@@ -619,6 +619,11 @@ export const seriesApi = {
const response = await api.get(`/stories/series/${id}`); const response = await api.get(`/stories/series/${id}`);
return response.data; return response.data;
}, },
createCollectionFromSeries: async (seriesId: string): Promise<{ collectionId: string; name: string }> => {
const response = await api.post(`/series/${seriesId}/create-collection`);
return response.data;
},
}; };
// Search endpoints // Search endpoints
@@ -1169,6 +1174,11 @@ export const audioCoveApi = {
const response = await api.post(`/stories/${storyId}/send-to-audiocove`); const response = await api.post(`/stories/${storyId}/send-to-audiocove`);
return response.data; return response.data;
}, },
sendCollectionToAudioCove: async (collectionId: string): Promise<string> => {
const response = await api.post(`/collections/${collectionId}/send-to-audiocove`);
return response.data;
},
}; };
// Image utility - now library-aware // Image utility - now library-aware

File diff suppressed because one or more lines are too long