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.Story;
import com.storycove.entity.Tag;
import com.storycove.service.AudioCoveService;
import com.storycove.service.CollectionService;
import com.storycove.service.EPUBExportService;
import com.storycove.service.ImageService;
@@ -29,16 +30,19 @@ public class CollectionController {
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
private final CollectionService collectionService;
private final AudioCoveService audioCoveService;
private final ImageService imageService;
private final ReadingTimeService readingTimeService;
private final EPUBExportService epubExportService;
@Autowired
public CollectionController(CollectionService collectionService,
AudioCoveService audioCoveService,
ImageService imageService,
ReadingTimeService readingTimeService,
EPUBExportService epubExportService) {
this.collectionService = collectionService;
this.audioCoveService = audioCoveService;
this.imageService = imageService;
this.readingTimeService = readingTimeService;
this.epubExportService = epubExportService;
@@ -281,6 +285,25 @@ public class CollectionController {
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
*/

View File

@@ -1,7 +1,9 @@
package com.storycove.controller;
import com.storycove.dto.SeriesDto;
import com.storycove.entity.Collection;
import com.storycove.entity.Series;
import com.storycove.service.CollectionService;
import com.storycove.service.SeriesService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
@@ -22,9 +24,11 @@ import java.util.stream.Collectors;
public class SeriesController {
private final SeriesService seriesService;
public SeriesController(SeriesService seriesService) {
private final CollectionService collectionService;
public SeriesController(SeriesService seriesService, CollectionService collectionService) {
this.seriesService = seriesService;
this.collectionService = collectionService;
}
@GetMapping
@@ -128,6 +132,15 @@ public class SeriesController {
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) {
if (request instanceof CreateSeriesRequest createReq) {
series.setName(createReq.getName());

View File

@@ -2,6 +2,8 @@ package com.storycove.service;
import com.storycove.config.AudioCoveProperties;
import com.storycove.dto.AudioCoveIngestRequest;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,6 +17,7 @@ import org.springframework.web.client.RestClientException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
@@ -24,14 +27,17 @@ public class AudioCoveService {
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
private final StoryService storyService;
private final CollectionService collectionService;
private final AudioCoveProperties audioCoveProperties;
private final String publicUrl;
private final RestClient restClient;
public AudioCoveService(StoryService storyService,
CollectionService collectionService,
AudioCoveProperties audioCoveProperties,
@Value("${storycove.app.public-url}") String publicUrl) {
this.storyService = storyService;
this.collectionService = collectionService;
this.audioCoveProperties = audioCoveProperties;
this.publicUrl = publicUrl;
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) {
AudioCoveIngestRequest request = new AudioCoveIngestRequest();

View File

@@ -10,6 +10,7 @@ import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.CollectionRepository;
import com.storycove.repository.CollectionStoryRepository;
import com.storycove.repository.SeriesRepository;
import com.storycove.repository.StoryRepository;
import com.storycove.repository.TagRepository;
import com.storycove.service.exception.ResourceNotFoundException;
@@ -32,20 +33,23 @@ public class CollectionService {
private final CollectionStoryRepository collectionStoryRepository;
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
private final SeriesRepository seriesRepository;
private final SearchServiceAdapter searchServiceAdapter;
private final ReadingTimeService readingTimeService;
@Autowired
public CollectionService(CollectionRepository collectionRepository,
CollectionStoryRepository collectionStoryRepository,
StoryRepository storyRepository,
TagRepository tagRepository,
SeriesRepository seriesRepository,
SearchServiceAdapter searchServiceAdapter,
ReadingTimeService readingTimeService) {
this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository;
this.tagRepository = tagRepository;
this.seriesRepository = seriesRepository;
this.searchServiceAdapter = searchServiceAdapter;
this.readingTimeService = readingTimeService;
}
@@ -416,6 +420,26 @@ public class CollectionService {
.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)
*/

View File

@@ -279,7 +279,11 @@ public class DatabaseManagementService {
Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql");
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(
"pg_dump",
"--host=" + dbHost,
@@ -290,7 +294,8 @@ public class DatabaseManagementService {
"--verbose",
"--clean",
"--if-exists",
"--create",
"--no-owner",
"--no-acl",
"--file=" + tempBackupFile.toString()
);
@@ -368,12 +373,18 @@ public class DatabaseManagementService {
String line;
while ((line = reader.readLine()) != null) {
// Skip DROP DATABASE and CREATE DATABASE commands - we're already connected to the DB
// Also skip database connection commands as we're already connected
if (line.trim().startsWith("DROP DATABASE") ||
line.trim().startsWith("CREATE DATABASE") ||
line.trim().startsWith("\\connect")) {
System.err.println("Skipping incompatible command: " + line.substring(0, Math.min(50, line.length())));
// Skip database-level commands that were generated by older backups using
// pg_dump --create and that would fail when restoring to a different environment.
// New backups no longer use --create so these lines won't appear, but we keep
// the filters for backward compatibility with previously created backups.
String trimmed = line.trim();
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;
}
writer.write(line);
@@ -383,7 +394,10 @@ public class DatabaseManagementService {
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(
"psql",
"--host=" + dbHost,
@@ -392,6 +406,7 @@ public class DatabaseManagementService {
"--dbname=" + dbName,
"--no-password",
"--echo-errors",
"--single-transaction",
"--file=" + tempBackupFile.toString()
);